issue/6-generacin-de-materias #16
@@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"allowScripts": ["npm:deno@2.6.4", "npm:supabase-js@1.0.4", "npm:supabase@2.72.6"],
|
"allowScripts": [
|
||||||
|
"npm:deno@2.6.4",
|
||||||
|
"npm:supabase-js@1.0.4",
|
||||||
|
"npm:supabase@2.72.6"
|
||||||
|
],
|
||||||
"imports": {
|
"imports": {
|
||||||
"@openai/openai": "jsr:@openai/openai@^6.16.0",
|
"@openai/openai": "jsr:@openai/openai@^6.16.0",
|
||||||
"@zod/zod": "jsr:@zod/zod@^4.3.5"
|
"@zod/zod": "jsr:@zod/zod@^4.3.5",
|
||||||
"@david/dax": "jsr:@david/dax@^0.44.2"
|
"@david/dax": "jsr:@david/dax@^0.44.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
v2.72.7
|
v2.75.0
|
||||||
@@ -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
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$
|
|
||||||
;
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const corsHeaders = {
|
export const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers":
|
"Access-Control-Allow-Headers":
|
||||||
"authorization, x-client-info, apikey, content-type",
|
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
import OpenAI from "npm:openai@6.16.0";
|
import OpenAI from "npm:openai@6.16.0";
|
||||||
import type * as OpenAITypes from "npm:openai@6.16.0";
|
import type * as OpenAITypes from "npm:openai@6.16.0";
|
||||||
import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2";
|
import { createClient, type SupabaseClient } from "npm:@supabase/supabase-js@2";
|
||||||
|
|
||||||
// Use non-streaming params to ensure `responses.create` returns a typed Response
|
// Use non-streaming params to ensure `responses.create` returns a typed Response
|
||||||
export type StructuredResponseOptions =
|
export type StructuredResponseOptions =
|
||||||
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
|
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
|
||||||
|
|
||||||
export type StructuredResponseSuccess<TOutput = unknown> = {
|
export type StructuredResponseSuccess<TOutput = unknown> = {
|
||||||
ok: true;
|
ok: true;
|
||||||
output?: TOutput; // parsed JSON when available
|
output?: TOutput; // parsed JSON when available
|
||||||
@@ -22,7 +20,6 @@ export type StructuredResponseSuccess<TOutput = unknown> = {
|
|||||||
};
|
};
|
||||||
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructuredResponseFailure = {
|
export type StructuredResponseFailure = {
|
||||||
ok: false;
|
ok: false;
|
||||||
code:
|
code:
|
||||||
@@ -33,23 +30,19 @@ export type StructuredResponseFailure = {
|
|||||||
message: string;
|
message: string;
|
||||||
cause?: unknown;
|
cause?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructuredResponseResult<TOutput = unknown> =
|
export type StructuredResponseResult<TOutput = unknown> =
|
||||||
| StructuredResponseSuccess<TOutput>
|
| StructuredResponseSuccess<TOutput>
|
||||||
| StructuredResponseFailure;
|
| StructuredResponseFailure;
|
||||||
|
|
||||||
export interface OpenAIServiceConfig {
|
export interface OpenAIServiceConfig {
|
||||||
supabaseUrl: string;
|
supabaseUrl: string;
|
||||||
serviceRoleKey: string;
|
serviceRoleKey: string;
|
||||||
openAIApiKey: string;
|
openAIApiKey: string;
|
||||||
bucket?: string; // default: ai-storage
|
bucket?: string; // default: ai-storage
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenAIService {
|
export class OpenAIService {
|
||||||
private readonly supabase: SupabaseClient;
|
private readonly supabase: SupabaseClient;
|
||||||
private readonly openai: OpenAI;
|
private readonly openai: OpenAI;
|
||||||
private readonly bucket: string;
|
private readonly bucket: string;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
client: SupabaseClient,
|
client: SupabaseClient,
|
||||||
openai: OpenAI,
|
openai: OpenAI,
|
||||||
@@ -59,13 +52,11 @@ export class OpenAIService {
|
|||||||
this.openai = openai;
|
this.openai = openai;
|
||||||
this.bucket = bucket;
|
this.bucket = bucket;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||||
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||||
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||||
|
|
||||||
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -74,12 +65,16 @@ export class OpenAIService {
|
|||||||
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient(supabaseUrl, serviceRoleKey);
|
const client = createClient(supabaseUrl, serviceRoleKey);
|
||||||
const openai = new OpenAI({ apiKey: openAIApiKey });
|
const openai = new OpenAI({ apiKey: openAIApiKey });
|
||||||
return new OpenAIService(client, openai, bucket);
|
return new OpenAIService(client, openai, bucket);
|
||||||
}
|
}
|
||||||
|
async createConversation(metadata?: Record<string, string>) {
|
||||||
|
const conversation = await this.openai.conversations.create({
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
async createStructuredResponse<TOutput = unknown>(
|
async createStructuredResponse<TOutput = unknown>(
|
||||||
options: StructuredResponseOptions,
|
options: StructuredResponseOptions,
|
||||||
files?: File[],
|
files?: File[],
|
||||||
@@ -91,7 +86,6 @@ export class OpenAIService {
|
|||||||
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
||||||
|
|
||||||
const newOptions = { ...options };
|
const newOptions = { ...options };
|
||||||
|
|
||||||
// Attach file references to the request as an extra user message
|
// Attach file references to the request as an extra user message
|
||||||
if (openaiFileIds.length > 0) {
|
if (openaiFileIds.length > 0) {
|
||||||
const fileParts:
|
const fileParts:
|
||||||
@@ -100,7 +94,6 @@ export class OpenAIService {
|
|||||||
type: "input_file",
|
type: "input_file",
|
||||||
file_id: id,
|
file_id: id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const arr = Array.isArray(options.input) ? options.input : [];
|
const arr = Array.isArray(options.input) ? options.input : [];
|
||||||
arr.push({
|
arr.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -119,7 +112,6 @@ export class OpenAIService {
|
|||||||
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,
|
||||||
)) as OpenAITypes.OpenAI.Responses.Response;
|
)) as OpenAITypes.OpenAI.Responses.Response;
|
||||||
|
|
||||||
const { model, id: responseId } = openaiRaw;
|
const { model, id: responseId } = openaiRaw;
|
||||||
const usage = openaiRaw?.usage ?? null;
|
const usage = openaiRaw?.usage ?? null;
|
||||||
const conversationId = (
|
const conversationId = (
|
||||||
@@ -127,11 +119,9 @@ export class OpenAIService {
|
|||||||
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;
|
||||||
let outputText: string | undefined = undefined;
|
let outputText: string | undefined = undefined;
|
||||||
|
|
||||||
// Prefer `output_text` if present (SDK convenience)
|
// Prefer `output_text` if present (SDK convenience)
|
||||||
const maybeOutputText = openaiRaw.output_text;
|
const maybeOutputText = openaiRaw.output_text;
|
||||||
if (
|
if (
|
||||||
@@ -141,7 +131,9 @@ export class OpenAIService {
|
|||||||
outputText = maybeOutputText;
|
outputText = maybeOutputText;
|
||||||
try {
|
try {
|
||||||
output = JSON.parse(maybeOutputText) as TOutput;
|
output = JSON.parse(maybeOutputText) as TOutput;
|
||||||
} catch { /* non-JSON text, keep as text only */ }
|
} catch {
|
||||||
|
/* non-JSON text, keep as text only */
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: attempt to serialize `openaiRaw.output` into text
|
// Fallback: attempt to serialize `openaiRaw.output` into text
|
||||||
const maybeOutput = openaiRaw.output as unknown;
|
const maybeOutput = openaiRaw.output as unknown;
|
||||||
@@ -149,10 +141,11 @@ export class OpenAIService {
|
|||||||
try {
|
try {
|
||||||
outputText = JSON.stringify(maybeOutput);
|
outputText = JSON.stringify(maybeOutput);
|
||||||
output = maybeOutput as TOutput;
|
output = maybeOutput as TOutput;
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
output,
|
output,
|
||||||
@@ -172,11 +165,9 @@ export class OpenAIService {
|
|||||||
: 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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
|
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -188,7 +179,6 @@ export class OpenAIService {
|
|||||||
contentType: file.type || "application/octet-stream",
|
contentType: file.type || "application/octet-stream",
|
||||||
upsert: false,
|
upsert: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error(`Supabase upload failed: ${error.message}`);
|
throw new Error(`Supabase upload failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -196,7 +186,6 @@ export class OpenAIService {
|
|||||||
}
|
}
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
|
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -216,7 +205,6 @@ export class OpenAIService {
|
|||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeFilename(name: string): string {
|
private sanitizeFilename(name: string): string {
|
||||||
return name
|
return name
|
||||||
.normalize("NFD")
|
.normalize("NFD")
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { corsHeaders } from "./cors.ts";
|
||||||
|
|
||||||
|
// --- CLASE DE ERROR ---
|
||||||
|
export class HttpError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly code: string;
|
||||||
|
public readonly internalDetails: unknown;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
code = "API_ERROR",
|
||||||
|
internalDetails?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HttpError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.internalDetails = internalDetails;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HELPER DE ÉXITO ---
|
||||||
|
export function sendSuccess<T>(data: T, status = 200): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(data),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HELPER DE ERROR ---
|
||||||
|
export function sendError(
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: { message, code },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts"
|
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
|
||||||
|
"openai": "npm:openai@6.16.0",
|
||||||
|
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
|
||||||
|
"@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Setup type definitions for built-in Supabase Runtime APIs
|
// Setup type definitions for built-in Supabase Runtime APIs
|
||||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
import type { Database, Json } from "../_shared/database.types.ts";
|
import type { Database, Json } from "../_shared/database.types.ts";
|
||||||
import type { AIGeneratePlanInput } from "./types.ts";
|
import type { AIGeneratePlanInput } from "./types.ts";
|
||||||
@@ -18,7 +19,7 @@ type NivelType =
|
|||||||
type TipoCicloType =
|
type TipoCicloType =
|
||||||
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
Database["public"]["Tables"]["planes_estudio"]["Insert"]["tipo_ciclo"];
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req: Request): Promise<Response> => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const functionName = url.pathname.split("/").pop();
|
const functionName = url.pathname.split("/").pop();
|
||||||
console.log(
|
console.log(
|
||||||
@@ -37,12 +38,11 @@ Deno.serve(async (req) => {
|
|||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Invalid method: ${method}`,
|
}][${functionName}]: Invalid method: ${method}`,
|
||||||
);
|
);
|
||||||
return new Response(
|
throw new HttpError(
|
||||||
JSON.stringify({ error: "Method not allowed" }),
|
405,
|
||||||
{
|
"Método no permitido.",
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
"METHOD_NOT_ALLOWED",
|
||||||
status: 405,
|
{ method },
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +54,11 @@ Deno.serve(async (req) => {
|
|||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Missing Authorization header`,
|
}][${functionName}]: Missing Authorization header`,
|
||||||
);
|
);
|
||||||
return new Response(
|
throw new HttpError(
|
||||||
JSON.stringify({ error: "Authorization header missing" }),
|
401,
|
||||||
{
|
"No autorizado.",
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
"UNAUTHORIZED",
|
||||||
status: 401,
|
{ reason: "missing_authorization_header" },
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +69,28 @@ Deno.serve(async (req) => {
|
|||||||
new Date().toISOString()
|
new Date().toISOString()
|
||||||
}][${functionName}]: Unsupported content type: ${contentType}`,
|
}][${functionName}]: Unsupported content type: ${contentType}`,
|
||||||
);
|
);
|
||||||
return new Response(
|
throw new HttpError(
|
||||||
JSON.stringify({ error: "Unsupported content type" }),
|
415,
|
||||||
{
|
"Content-Type no soportado.",
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
"UNSUPPORTED_MEDIA_TYPE",
|
||||||
status: 400,
|
{ contentType },
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||||
throw new Error("Supabase environment variables are not set");
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"Configuración del servidor incompleta.",
|
||||||
|
"MISSING_ENV",
|
||||||
|
{
|
||||||
|
missing: [
|
||||||
|
!SUPABASE_URL ? "SUPABASE_URL" : null,
|
||||||
|
!SUPABASE_ANON_KEY ? "SUPABASE_ANON_KEY" : null,
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If needed for RLS-protected reads, create an anon client with user's JWT
|
// If needed for RLS-protected reads, create an anon client with user's JWT
|
||||||
@@ -97,7 +105,12 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
if (!SERVICE_ROLE_KEY) {
|
if (!SERVICE_ROLE_KEY) {
|
||||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
|
throw new HttpError(
|
||||||
|
500,
|
||||||
|
"Configuración del servidor incompleta.",
|
||||||
|
"MISSING_ENV",
|
||||||
|
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseService = createClient<Database>(
|
const supabaseService = createClient<Database>(
|
||||||
@@ -112,12 +125,15 @@ Deno.serve(async (req) => {
|
|||||||
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
||||||
validation.errors,
|
validation.errors,
|
||||||
);
|
);
|
||||||
return new Response(
|
const message = validation.errors
|
||||||
JSON.stringify({ errors: validation.errors }),
|
.map((e, i) => `(${i + 1}) ${e}`)
|
||||||
{
|
.join("\n");
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
status: 400,
|
throw new HttpError(
|
||||||
},
|
422,
|
||||||
|
message,
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
{ errors: validation.errors },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,15 +146,37 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPrompt =
|
const userPrompt =
|
||||||
`Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:
|
`Genera un borrador completo del PLAN DE ESTUDIOS con base en lo siguiente:
|
||||||
- Descripción del enfoque: ${payload.iaConfig.descripcionEnfoque}
|
- Nombre de la institución: Universidad La Salle México
|
||||||
- Notas adicionales: ${payload.iaConfig.notasAdicionales ?? "Ninguna"}`;
|
- Nombre del plan: ${payload.datosBasicos.nombrePlan}
|
||||||
|
- Nivel: ${payload.datosBasicos.nivel}
|
||||||
|
- Tipo de ciclo: ${payload.datosBasicos.tipoCiclo}
|
||||||
|
- Número de ciclos: ${payload.datosBasicos.numCiclos}
|
||||||
|
- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${payload.iaConfig.descripcionEnfoqueAcademico}
|
||||||
|
- Notas adicionales (sobre el formato de la respuesta generada): ${
|
||||||
|
payload.iaConfig.instruccionesAdicionalesIA ?? "Ninguna"
|
||||||
|
}`;
|
||||||
// Ensure the JSON schema is an object as required by OpenAI types
|
// Ensure the JSON schema is an object as required by OpenAI types
|
||||||
const schemaDef: Record<string, unknown> =
|
const schemaDef: Record<string, unknown> =
|
||||||
typeof estructuraPlan?.definicion === "object" &&
|
typeof estructuraPlan?.definicion === "object" &&
|
||||||
@@ -165,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
|
||||||
@@ -202,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"] =
|
||||||
@@ -230,8 +320,10 @@ Deno.serve(async (req) => {
|
|||||||
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
repositoriosIds: payload.iaConfig?.repositoriosIds ?? null,
|
||||||
},
|
},
|
||||||
iaConfig: {
|
iaConfig: {
|
||||||
descripcionEnfoque: payload.iaConfig?.descripcionEnfoque ?? null,
|
descripcionEnfoqueAcademico:
|
||||||
notasAdicionales: payload.iaConfig?.notasAdicionales ?? null,
|
payload.iaConfig?.descripcionEnfoqueAcademico ?? null,
|
||||||
|
instruccionesAdicionalesIA:
|
||||||
|
payload.iaConfig?.instruccionesAdicionalesIA ?? null,
|
||||||
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
usarMCP: Boolean(payload.iaConfig?.usarMCP),
|
||||||
},
|
},
|
||||||
} as unknown as Json,
|
} as unknown as Json,
|
||||||
@@ -248,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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,8 +427,8 @@ const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
|
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
|
||||||
descripcionEnfoque: z.string().min(10, "La descripción es muy corta"),
|
descripcionEnfoqueAcademico: z.string(),
|
||||||
notasAdicionales: z.string().optional(),
|
instruccionesAdicionalesIA: z.string().optional(),
|
||||||
archivosReferencia: z.array(z.string().uuid()).optional(),
|
archivosReferencia: z.array(z.string().uuid()).optional(),
|
||||||
repositoriosIds: z.array(z.string().uuid()).optional(),
|
repositoriosIds: z.array(z.string().uuid()).optional(),
|
||||||
usarMCP: z.boolean().optional(),
|
usarMCP: z.boolean().optional(),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export type AIGeneratePlanInput = {
|
|||||||
estructuraPlanId: string;
|
estructuraPlanId: string;
|
||||||
};
|
};
|
||||||
iaConfig: {
|
iaConfig: {
|
||||||
descripcionEnfoque: string;
|
descripcionEnfoqueAcademico: string;
|
||||||
notasAdicionales?: string;
|
instruccionesAdicionalesIA?: string;
|
||||||
archivosReferencia?: Array<string>;
|
archivosReferencia?: Array<string>;
|
||||||
repositoriosIds?: Array<string>;
|
repositoriosIds?: Array<string>;
|
||||||
usarMCP?: boolean;
|
usarMCP?: boolean;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// Setup type definitions for built-in Supabase Runtime APIs
|
// Setup type definitions for built-in Supabase Runtime APIs
|
||||||
import "@supabase/functions-js/edge-runtime.d.ts";
|
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
type WebhookPayload = {
|
type WebhookPayload = {
|
||||||
type: "INSERT" | "UPDATE" | "DELETE";
|
type: "INSERT" | "UPDATE" | "DELETE";
|
||||||
table: string;
|
table: string;
|
||||||
@@ -9,28 +8,23 @@ type WebhookPayload = {
|
|||||||
record: Record<string, unknown> | null;
|
record: Record<string, unknown> | null;
|
||||||
old_record: Record<string, unknown> | null;
|
old_record: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
||||||
const SUPABASE_SERVICE_ROLE_KEY =
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ??
|
||||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
"";
|
||||||
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? "";
|
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||||
const OPENAI_BASE_URL =
|
const OPENAI_BASE_URL = Deno.env.get("OPENAI_BASE_URL") ??
|
||||||
Deno.env.get("OPENAI_BASE_URL") ?? "https://api.openai.com/v1";
|
"https://api.openai.com/v1";
|
||||||
|
|
||||||
const ALLOWED_SCHEMA = "public";
|
const ALLOWED_SCHEMA = "public";
|
||||||
const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]);
|
const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]);
|
||||||
|
|
||||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
auth: { persistSession: false },
|
auth: { persistSession: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(status: number, body: Record<string, unknown>) {
|
function jsonResponse(status: number, body: Record<string, unknown>) {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
status,
|
status,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createConversationId(metadata: Record<string, string>) {
|
async function createConversationId(metadata: Record<string, string>) {
|
||||||
const response = await fetch(`${OPENAI_BASE_URL}/conversations`, {
|
const response = await fetch(`${OPENAI_BASE_URL}/conversations`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -40,61 +34,47 @@ async function createConversationId(metadata: Record<string, string>) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ metadata }),
|
body: JSON.stringify({ metadata }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(`OpenAI error: ${response.status} ${errorText}`);
|
throw new Error(`OpenAI error: ${response.status} ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const conversationId = data?.id as string | undefined;
|
const conversationId = data?.id as string | undefined;
|
||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
throw new Error("OpenAI response missing conversation id");
|
throw new Error("OpenAI response missing conversation id");
|
||||||
}
|
}
|
||||||
|
|
||||||
return conversationId;
|
return conversationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return jsonResponse(405, { error: "Method not allowed" });
|
return jsonResponse(405, { error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||||
return jsonResponse(500, { error: "Supabase env vars missing" });
|
return jsonResponse(500, { error: "Supabase env vars missing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!OPENAI_API_KEY) {
|
if (!OPENAI_API_KEY) {
|
||||||
return jsonResponse(500, { error: "OPENAI_API_KEY missing" });
|
return jsonResponse(500, { error: "OPENAI_API_KEY missing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: WebhookPayload;
|
let payload: WebhookPayload;
|
||||||
try {
|
try {
|
||||||
payload = await req.json();
|
payload = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return jsonResponse(400, { error: "Invalid JSON body" });
|
return jsonResponse(400, { error: "Invalid JSON body" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, table, schema, record } = payload;
|
const { type, table, schema, record } = payload;
|
||||||
|
|
||||||
if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) {
|
if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) {
|
||||||
return jsonResponse(400, { error: "Table not allowed" });
|
return jsonResponse(400, { error: "Table not allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type !== "INSERT") {
|
if (type !== "INSERT") {
|
||||||
return jsonResponse(200, { status: "ignored", reason: "event_type" });
|
return jsonResponse(200, { status: "ignored", reason: "event_type" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!record || typeof record !== "object") {
|
if (!record || typeof record !== "object") {
|
||||||
return jsonResponse(400, { error: "Missing record" });
|
return jsonResponse(400, { error: "Missing record" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordId = record["id"] as string | number | undefined;
|
const recordId = record["id"] as string | number | undefined;
|
||||||
if (!recordId) {
|
if (!recordId) {
|
||||||
return jsonResponse(400, { error: "Record id missing" });
|
return jsonResponse(400, { error: "Record id missing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingConversationId = record["conversation_id"] as
|
const existingConversationId = record["conversation_id"] as
|
||||||
| string
|
| string
|
||||||
| null
|
| null
|
||||||
@@ -105,7 +85,6 @@ Deno.serve(async (req) => {
|
|||||||
conversation_id: existingConversationId,
|
conversation_id: existingConversationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let conversationId: string;
|
let conversationId: string;
|
||||||
try {
|
try {
|
||||||
conversationId = await createConversationId({
|
conversationId = await createConversationId({
|
||||||
@@ -115,16 +94,13 @@ Deno.serve(async (req) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return jsonResponse(502, { error: (error as Error).message });
|
return jsonResponse(502, { error: (error as Error).message });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from(table)
|
.from(table)
|
||||||
.update({ conversation_id: conversationId })
|
.update({ conversation_id: conversationId })
|
||||||
.eq("id", recordId);
|
.eq("id", recordId);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return jsonResponse(500, { error: error.message });
|
return jsonResponse(500, { error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse(200, {
|
return jsonResponse(200, {
|
||||||
status: "updated",
|
status: "updated",
|
||||||
table,
|
table,
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user