Compare commits
7 Commits
0d00ce1dc1
...
00f0bddf1c
| Author | SHA1 | Date | |
|---|---|---|---|
| 00f0bddf1c | |||
| 1ca861c51d | |||
| 42a437afa3 | |||
| 7660d2c3a0 | |||
| e5657d6b0f | |||
| b1a90858dc | |||
| 132a51133b |
@@ -89,33 +89,57 @@
|
||||
"npm": {
|
||||
"@deno/darwin-arm64@2.6.4": {
|
||||
"integrity": "sha512-5pocuV2IVWYUzomXze+bDSO86uX1WPxUn82BS7BrLI3ix8Eq7l8DhvYgz064w7rwCM4CiLLSc6MAQpAI+0gUag==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"]
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
"@deno/darwin-x64@2.6.4": {
|
||||
"integrity": "sha512-tX7Fz47HcoXTYR4wN2WzXjqJtEPTftzjEknCYkZa8D4BzvpN7Zo1ra3Jd6bWPN9+T6mgTL6YMPMMSigXddIUcA==",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"]
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"@deno/linux-arm64-glibc@2.6.4": {
|
||||
"integrity": "sha512-wY4hjjVLq5idClWj6e1de9IUZ0QZ1rxo16o0dwCJpMi+w1lolxtYxGF4y7pkcxAdZOpMGsXZuYMPq1Z2n51esA==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"]
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
"@deno/linux-x64-glibc@2.6.4": {
|
||||
"integrity": "sha512-H9b0WJw+eWyC6GcMGCXxC/3lz2kVveyWyUrzGyQ8hJmtDJpWiDnCU2iPqEpEcwXHgAUFFyjnRaFwedwwv5ApdA==",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"]
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"@deno/win32-arm64@2.6.4": {
|
||||
"integrity": "sha512-fMH1dx54ORbCTpMorGjDshwonIu6KfgfPC+Pq+14jeq/8604EIFXiA484p3Ov+ViZycndNHEPe8ZKPNd8xLjJA==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["arm64"]
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
"@deno/win32-x64@2.6.4": {
|
||||
"integrity": "sha512-hAxqNrBLHCINtqDgKcRiu8QtMbtdnHOVW3OFQjzA3gZEcpnGUK3JiF5ql++83SjuQH8ViZKGcdqkioLR7Bf14A==",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"]
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"@isaacs/fs-minipass@4.0.1": {
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
@@ -352,7 +376,8 @@
|
||||
"dependencies": [
|
||||
"jsr:@david/dax@~0.44.2",
|
||||
"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": {
|
||||
"dependencies": [
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"nombre_autorizado_de_la_institucion",
|
||||
"nivel_y_nombre_del_plan_de_estudios",
|
||||
"modalidad_educativa",
|
||||
"vigencia",
|
||||
"antecedente_academico",
|
||||
"area_de_estudio",
|
||||
"clave_del_plan_de_estudios",
|
||||
"diseno_curricular",
|
||||
"total_de_ciclos_del_plan_de_estudios",
|
||||
"duracion_del_ciclo_escolar",
|
||||
"carga_horaria_a_la_semana",
|
||||
"fines_de_aprendizaje_o_formacion",
|
||||
"perfil_de_egreso",
|
||||
"programa_de_investigacion",
|
||||
"curso_propedeutico",
|
||||
"perfil_de_ingreso",
|
||||
"administracion_y_operatividad_del_plan_de_estudios",
|
||||
"sustento_teorico_del_modelo_curricular",
|
||||
"justificacion_de_la_propuesta_curricular",
|
||||
"propuesta_de_evaluacion_periodica_del_plan_de_estudios",
|
||||
"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios"
|
||||
],
|
||||
"properties": {
|
||||
"nombre_autorizado_de_la_institucion": {
|
||||
"type": "string",
|
||||
"title": "Nombre autorizado de la institución",
|
||||
"examples": ["Universidad La Salle A.C."],
|
||||
"description": "Proporcionar el nombre de la institución autorizado por SEP.",
|
||||
"referencia_normativa": "Dato administrativo del Anexo 1."
|
||||
},
|
||||
"nivel_y_nombre_del_plan_de_estudios": {
|
||||
"type": "string",
|
||||
"title": "Nivel y nombre del plan de estudios",
|
||||
"examples": [
|
||||
"Licenciatura en Derecho",
|
||||
"Maestría en Ciencias de la Computación"
|
||||
],
|
||||
"description": "Indicar el nivel educativo y denominación del plan de estudios.",
|
||||
"referencia_normativa": "Corresponde al requisito 'Nivel y denominación del plan de estudios' del Artículo 8, fracción I del Acuerdo 17/11/17."
|
||||
},
|
||||
"modalidad_educativa": {
|
||||
"enum": ["Escolar", "No escolarizada", "Mixta"],
|
||||
"type": "string",
|
||||
"title": "Modalidad educativa",
|
||||
"examples": ["Escolar"],
|
||||
"description": "Señalar la modalidad educativa en la que se impartirá el plan de estudios: escolar, no escolarizada o mixta.",
|
||||
"referencia_normativa": "Corresponde al requisito de 'Modalidad educativa' del Artículo 8, fracción II del Acuerdo 17/11/17."
|
||||
},
|
||||
"vigencia": {
|
||||
"type": "string",
|
||||
"title": "Vigencia",
|
||||
"examples": ["2024-2029"],
|
||||
"description": "(Espacio para uso exclusivo de la autoridad educativa)",
|
||||
"referencia_normativa": "Dato administrativo del Anexo 1."
|
||||
},
|
||||
"antecedente_academico": {
|
||||
"type": "string",
|
||||
"title": "Antecedente académico",
|
||||
"examples": [
|
||||
"Bachillerato concluido o equivalente.",
|
||||
"Título de Licenciatura en áreas afines a la salud."
|
||||
],
|
||||
"description": "Señalar el nivel educativo requerido para ingresar al plan de estudios. Para posgrados, además del nivel educativo; indicar la(s) disciplina(s) o área(s) afín(es) al plan de estudios.",
|
||||
"referencia_normativa": "Corresponde al requisito 'Perfil de ingreso' (antecedente académico necesario) del Artículo 8, fracción V del Acuerdo 17/11/17."
|
||||
},
|
||||
"area_de_estudio": {
|
||||
"type": "string",
|
||||
"title": "Área de estudio",
|
||||
"examples": [
|
||||
"Ingeniería, industria y construcción",
|
||||
"Ciencias sociales y derecho"
|
||||
],
|
||||
"description": "Señalar el “campo amplio” al que pertenece el plan de estudios. Para ello, considerar la Clasificación Mexicana de Planes de Estudio por Campos de Formación Académica (CMPE 2016), publicado por INEGI.",
|
||||
"referencia_normativa": "Deberá vincularse con el fin de aprendizaje y perfil de egreso del plan de estudios."
|
||||
},
|
||||
"clave_del_plan_de_estudios": {
|
||||
"type": "string",
|
||||
"title": "Clave / Año del plan",
|
||||
"examples": ["2024"],
|
||||
"description": "Señalar el año en el que solicita el trámite.",
|
||||
"referencia_normativa": "Dato administrativo del Anexo 1."
|
||||
},
|
||||
"diseno_curricular": {
|
||||
"enum": ["Rígido", "Flexible"],
|
||||
"type": "string",
|
||||
"title": "Diseño curricular",
|
||||
"examples": ["Flexible"],
|
||||
"description": "Indicar si es “Rígido” o “Flexible”.",
|
||||
"referencia_normativa": "Define la estructura para el requisito 'Mapa curricular' del Artículo 8, fracción VII del Acuerdo 17/11/17."
|
||||
},
|
||||
"total_de_ciclos_del_plan_de_estudios": {
|
||||
"type": "string",
|
||||
"title": "Número de ciclos escolares que integrarán el plan de estudio",
|
||||
"examples": ["9 cuatrimestres", "8 semestres"],
|
||||
"description": "En diseño flexible especificar mínimo y máximo de ciclos. En diseño rígido especificar el total de ciclos.",
|
||||
"referencia_normativa": "Corresponde a 'señalando el número de ciclos en que se impartirá' del Artículo 8, fracción III del Acuerdo 17/11/17."
|
||||
},
|
||||
"duracion_del_ciclo_escolar": {
|
||||
"type": "string",
|
||||
"title": "Duración mínima en semanas de cada ciclo escolar",
|
||||
"examples": ["16 semanas"],
|
||||
"description": "Señalar el número mínimo de semanas efectivas de clase que integran el tipo de ciclo en el que se imparte el plan de estudios.",
|
||||
"referencia_normativa": "Corresponde exactamente al requisito 'Duración mínima en semanas' del Artículo 8, fracción III del Acuerdo 17/11/17."
|
||||
},
|
||||
"carga_horaria_a_la_semana": {
|
||||
"type": "number",
|
||||
"title": "Carga horaria a la semana",
|
||||
"examples": [25],
|
||||
"description": "Indicar la máxima carga horaria impartida a la semana; no mayor a 50 horas bajo la conducción de un académico.",
|
||||
"referencia_normativa": "Corresponde a la restricción 'sin exceder una carga máxima de 50 horas efectivas por semana' del Artículo 8, fracción III del Acuerdo 17/11/17."
|
||||
},
|
||||
"fines_de_aprendizaje_o_formacion": {
|
||||
"type": "string",
|
||||
"title": "Fin de aprendizaje o formación",
|
||||
"examples": [
|
||||
"El estudiante analizará los principios fundamentales del derecho penal..."
|
||||
],
|
||||
"description": "Describir los aprendizajes que lograrán los estudiantes al concluir el plan de estudios. Deberá corresponder con el nivel educativo y la denominación del plan de estudios.",
|
||||
"referencia_normativa": "Corresponde al requisito 'Descripción de los fines del aprendizaje o formación' del Artículo 8, fracción IV del Acuerdo 17/11/17."
|
||||
},
|
||||
"perfil_de_egreso": {
|
||||
"type": "string",
|
||||
"title": "Perfil de egreso",
|
||||
"examples": ["El egresado será capaz de diseñar estrategias legales..."],
|
||||
"description": "Describir los atributos que habrán desarrollado los estudiantes al concluir el plan de estudios, acordes al nivel educativo y denominación propuesta. De preferencia, dividirlo en conocimientos, habilidades y actitudes.",
|
||||
"referencia_normativa": "Corresponde al requisito 'Perfil de egreso' del Artículo 8, fracción VI del Acuerdo 17/11/17."
|
||||
},
|
||||
"programa_de_investigacion": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Programa de investigación",
|
||||
"examples": [
|
||||
"Línea 1: Innovación Educativa. Objetivo: Analizar el impacto de..."
|
||||
],
|
||||
"description": "(Obligatorio para el nivel de Doctorado y para aquéllas Maestrías orientadas a la investigación). Presentar el programa de investigación que describa los objetivos del programa y líneas de investigación con sus respectivos objetivos. Asimismo, detallar la vinculación con las asignaturas que conforman el plan de estudios, la metodología y presentación de las investigaciones, así como el perfil de los docentes que guiarán su desarrollo.",
|
||||
"referencia_normativa": "Requisito obligatorio solo para Doctorados o Maestrías con orientación a investigación según el Artículo 10, fracción III incisos b) y c) del Acuerdo 17/11/17."
|
||||
},
|
||||
"curso_propedeutico": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Curso propedéutico",
|
||||
"examples": [
|
||||
"Curso de nivelación de 40 horas obligatorio para aspirantes de áreas ajenas."
|
||||
],
|
||||
"description": "Señalar las situaciones en las que aplicará el curso propedéutico, la población a la que estará dirigido, su duración, la relación con el plan de estudios, así como la información que considere relevante.",
|
||||
"referencia_normativa": "Elemento opcional mencionado en las guías de llenado del Anexo 1."
|
||||
},
|
||||
"perfil_de_ingreso": {
|
||||
"type": "string",
|
||||
"title": "Perfil de ingreso",
|
||||
"examples": [
|
||||
"Conocimientos básicos de matemáticas, capacidad de análisis y redacción."
|
||||
],
|
||||
"description": "Indicar las condiciones académicas ideales (conocimientos, habilidades y aptitudes), para incorporarse al plan de estudios. Deberá vincularse con el nivel educativo y campo de estudio.",
|
||||
"referencia_normativa": "Corresponde al requisito 'Perfil de ingreso' (condiciones mínimas requeridas) del Artículo 8, fracción V del Acuerdo 17/11/17."
|
||||
},
|
||||
"administracion_y_operatividad_del_plan_de_estudios": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Administración y operatividad del plan de estudios",
|
||||
"examples": [
|
||||
"El estudiante podrá inscribir un mínimo de 3 y un máximo de 6 asignaturas por ciclo..."
|
||||
],
|
||||
"description": "Describir la administración del plan de estudios, así como su operatividad; considerando el tipo de diseño curricular empleado. Si es flexible; incorporar el número mínimo y máximo de asignaturas que se cursarán por ciclo.",
|
||||
"referencia_normativa": "Corresponde a la 'descripción detallada de la(s) forma(s) de administración y operatividad' permitida en el Artículo 8 (párrafo tercero) del Acuerdo 17/11/17."
|
||||
},
|
||||
"sustento_teorico_del_modelo_curricular": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Sustento teórico del modelo curricular",
|
||||
"examples": [
|
||||
"Modelo por competencias profesionales fundamentado en el constructivismo..."
|
||||
],
|
||||
"description": "Presentar de forma concreta el marco conceptual en el que se justifica y fundamenta el diseño y organización curricular del plan de estudios.",
|
||||
"referencia_normativa": "Corresponde al 'sustento teórico del modelo curricular' que el particular puede incluir según el Artículo 8 (párrafo tercero) del Acuerdo 17/11/17."
|
||||
},
|
||||
"justificacion_de_la_propuesta_curricular": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Justificación de la propuesta curricular en la modalidad no escolarizada o mixta",
|
||||
"examples": [
|
||||
"Se utilizará una plataforma LMS asincrónica complementada con sesiones síncronas..."
|
||||
],
|
||||
"description": "(Exclusivo para modalidades no escolarizada y mixta). Describir cómo se impartirá el plan de estudios en la modalidad educativa elegida, los recursos tecnológicos y/o didácticos que emplearán para su desarrollo y su vinculación con las asignaturas y actividades de aprendizaje.",
|
||||
"referencia_normativa": "Corresponde a lo solicitado en el Artículo 14 (justificación de modalidad) y Artículo 15 (especificaciones de plataforma/modelo) del Acuerdo 17/11/17."
|
||||
},
|
||||
"propuesta_de_evaluacion_periodica_del_plan_de_estudios": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Propuesta de evaluación periódica del plan de estudios",
|
||||
"examples": [
|
||||
"Revisión quinquenal mediante encuestas a empleadores, egresados y análisis de tendencias del mercado."
|
||||
],
|
||||
"description": "Describir de manera detallada la metodología que emplearán para la evaluación del plan de estudios, incluyendo los instrumentos y periodicidad en la que se llevará a cabo; con la finalidad de mantenerlo actualizado y acorde a las necesidades educativas y disciplinares.",
|
||||
"referencia_normativa": "Corresponde al requisito 'Propuesta de evaluación periódica del Plan de estudio' del Artículo 8, fracción VIII del Acuerdo 17/11/17."
|
||||
},
|
||||
"nombre_y_cargo_de_la_persona_facultada_para_autorizar_el_plan_de_estudios": {
|
||||
"type": "string",
|
||||
"title": "Nombre y cargo de la persona facultada para autorizar el plan de estudios",
|
||||
"examples": ["Dr. Juan Pérez Gómez, Rector de la Universidad XYZ"],
|
||||
"description": "Indicar el nombre y cargo de la persona facultada para autorizar el plan de estudios.",
|
||||
"referencia_normativa": "Dato administrativo del Anexo 1."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
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...");
|
||||
|
||||
|
||||
@@ -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.
|
||||
# For example, if you want to serve static HTML pages in your function:
|
||||
# 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$
|
||||
;
|
||||
|
||||
|
||||
@@ -3,200 +3,212 @@
|
||||
import OpenAI 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";
|
||||
|
||||
// Use non-streaming params to ensure `responses.create` returns a typed Response
|
||||
export type StructuredResponseOptions =
|
||||
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
|
||||
|
||||
OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming;
|
||||
export type StructuredResponseSuccess<TOutput = unknown> = {
|
||||
ok: true;
|
||||
output?: TOutput; // parsed JSON when available
|
||||
outputText?: string; // raw text when parsing is not possible
|
||||
model: string;
|
||||
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
|
||||
responseId: string;
|
||||
conversationId?: string | null;
|
||||
references: {
|
||||
uploadedToStorage: string[]; // supabase storage paths
|
||||
openaiFileIds: string[]; // file ids in OpenAI
|
||||
};
|
||||
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
||||
ok: true;
|
||||
output?: TOutput; // parsed JSON when available
|
||||
outputText?: string; // raw text when parsing is not possible
|
||||
model: string;
|
||||
usage?: OpenAITypes.OpenAI.Responses.Response["usage"] | null;
|
||||
responseId: string;
|
||||
conversationId?: string | null;
|
||||
references: {
|
||||
uploadedToStorage: string[]; // supabase storage paths
|
||||
openaiFileIds: string[]; // file ids in OpenAI
|
||||
};
|
||||
openaiRaw: OpenAITypes.OpenAI.Responses.Response; // keep for advanced consumers
|
||||
};
|
||||
|
||||
export type StructuredResponseFailure = {
|
||||
ok: false;
|
||||
code:
|
||||
| "MissingEnv"
|
||||
| "StorageUploadFailed"
|
||||
| "OpenAIFileUploadFailed"
|
||||
| "OpenAIRequestFailed";
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
ok: false;
|
||||
code:
|
||||
| "MissingEnv"
|
||||
| "StorageUploadFailed"
|
||||
| "OpenAIFileUploadFailed"
|
||||
| "OpenAIRequestFailed";
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
export type StructuredResponseResult<TOutput = unknown> =
|
||||
| StructuredResponseSuccess<TOutput>
|
||||
| StructuredResponseFailure;
|
||||
|
||||
| StructuredResponseSuccess<TOutput>
|
||||
| StructuredResponseFailure;
|
||||
export interface OpenAIServiceConfig {
|
||||
supabaseUrl: string;
|
||||
serviceRoleKey: string;
|
||||
openAIApiKey: string;
|
||||
bucket?: string; // default: ai-storage
|
||||
supabaseUrl: string;
|
||||
serviceRoleKey: string;
|
||||
openAIApiKey: string;
|
||||
bucket?: string; // default: ai-storage
|
||||
}
|
||||
|
||||
export class OpenAIService {
|
||||
private readonly supabase: SupabaseClient;
|
||||
private readonly openai: OpenAI;
|
||||
private readonly bucket: string;
|
||||
|
||||
private constructor(client: SupabaseClient, openai: OpenAI, bucket: string) {
|
||||
this.supabase = client;
|
||||
this.openai = openai;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "MissingEnv",
|
||||
message:
|
||||
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
||||
};
|
||||
private readonly supabase: SupabaseClient;
|
||||
private readonly openai: OpenAI;
|
||||
private readonly bucket: string;
|
||||
private constructor(
|
||||
client: SupabaseClient,
|
||||
openai: OpenAI,
|
||||
bucket: string,
|
||||
) {
|
||||
this.supabase = client;
|
||||
this.openai = openai;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
const client = createClient(supabaseUrl, serviceRoleKey);
|
||||
const openai = new OpenAI({ apiKey: openAIApiKey });
|
||||
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>(
|
||||
options: StructuredResponseOptions,
|
||||
files?: File[],
|
||||
): Promise<StructuredResponseResult<TOutput>> {
|
||||
try {
|
||||
const uploadedToStorage = await this.uploadFilesToStorage(files ?? []);
|
||||
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
||||
|
||||
// Narrow to non-streaming response
|
||||
const openaiRaw = (await this.openai.responses.create(
|
||||
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
|
||||
)) as OpenAITypes.OpenAI.Responses.Response;
|
||||
|
||||
const { model, id: responseId } = openaiRaw;
|
||||
const usage = openaiRaw?.usage ?? null;
|
||||
const conversationId =
|
||||
(
|
||||
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
).conversation_id ?? null;
|
||||
|
||||
// Try to read structured JSON output
|
||||
let output: TOutput | undefined = undefined;
|
||||
let outputText: string | undefined = undefined;
|
||||
|
||||
// Prefer `output_text` if present (SDK convenience)
|
||||
const maybeOutputText = openaiRaw.output_text;
|
||||
if (typeof maybeOutputText === "string" && maybeOutputText.length > 0) {
|
||||
outputText = maybeOutputText;
|
||||
static fromEnv(): StructuredResponseFailure | OpenAIService {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
const openAIApiKey = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||
const bucket = Deno.env.get("SUPABASE_BUCKET") ?? "ai-storage";
|
||||
if (!supabaseUrl || !serviceRoleKey || !openAIApiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "MissingEnv",
|
||||
message:
|
||||
"Required env vars missing: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY",
|
||||
};
|
||||
}
|
||||
const client = createClient(supabaseUrl, serviceRoleKey);
|
||||
const openai = new OpenAI({ apiKey: openAIApiKey });
|
||||
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>(
|
||||
options: StructuredResponseOptions,
|
||||
files?: File[],
|
||||
): Promise<StructuredResponseResult<TOutput>> {
|
||||
try {
|
||||
output = JSON.parse(maybeOutputText) as TOutput;
|
||||
} catch {
|
||||
/* non-JSON text, keep as text only */
|
||||
const uploadedToStorage = await this.uploadFilesToStorage(
|
||||
files ?? [],
|
||||
);
|
||||
const openaiFileIds = await this.uploadFilesToOpenAI(files ?? []);
|
||||
|
||||
const newOptions = { ...options };
|
||||
// Attach file references to the request as an extra user message
|
||||
if (openaiFileIds.length > 0) {
|
||||
const fileParts:
|
||||
OpenAITypes.OpenAI.Responses.ResponseInputFile[] =
|
||||
openaiFileIds.map((id) => ({
|
||||
type: "input_file",
|
||||
file_id: id,
|
||||
}));
|
||||
const arr = Array.isArray(options.input) ? options.input : [];
|
||||
arr.push({
|
||||
role: "user",
|
||||
content: [
|
||||
...fileParts,
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Usa estos archivos como referencia",
|
||||
},
|
||||
],
|
||||
});
|
||||
newOptions.input = arr;
|
||||
}
|
||||
|
||||
// Narrow to non-streaming response
|
||||
const openaiRaw = (await this.openai.responses.create(
|
||||
newOptions as OpenAITypes.OpenAI.Responses.ResponseCreateParamsNonStreaming,
|
||||
)) as OpenAITypes.OpenAI.Responses.Response;
|
||||
const { model, id: responseId } = openaiRaw;
|
||||
const usage = openaiRaw?.usage ?? null;
|
||||
const conversationId = (
|
||||
openaiRaw as OpenAITypes.OpenAI.Responses.Response & {
|
||||
conversation_id?: string | null;
|
||||
}
|
||||
).conversation_id ?? null;
|
||||
// Try to read structured JSON output
|
||||
let output: TOutput | undefined = undefined;
|
||||
let outputText: string | undefined = undefined;
|
||||
// Prefer `output_text` if present (SDK convenience)
|
||||
const maybeOutputText = openaiRaw.output_text;
|
||||
if (
|
||||
typeof maybeOutputText === "string" &&
|
||||
maybeOutputText.length > 0
|
||||
) {
|
||||
outputText = maybeOutputText;
|
||||
try {
|
||||
output = JSON.parse(maybeOutputText) as TOutput;
|
||||
} catch {
|
||||
/* non-JSON text, keep as text only */
|
||||
}
|
||||
} else {
|
||||
// Fallback: attempt to serialize `openaiRaw.output` into text
|
||||
const maybeOutput = openaiRaw.output as unknown;
|
||||
if (typeof maybeOutput === "object" && maybeOutput != null) {
|
||||
try {
|
||||
outputText = JSON.stringify(maybeOutput);
|
||||
output = maybeOutput as TOutput;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
output,
|
||||
outputText,
|
||||
model: String(model),
|
||||
usage,
|
||||
responseId: String(responseId),
|
||||
conversationId: conversationId ? String(conversationId) : null,
|
||||
references: { uploadedToStorage, openaiFileIds },
|
||||
openaiRaw,
|
||||
};
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
const message = e?.message ?? "Unknown error";
|
||||
const code = message.includes("Supabase upload failed")
|
||||
? "StorageUploadFailed"
|
||||
: message.includes("OpenAI file upload failed")
|
||||
? "OpenAIFileUploadFailed"
|
||||
: "OpenAIRequestFailed";
|
||||
return { ok: false, code, message, cause: err };
|
||||
}
|
||||
} else {
|
||||
// Fallback: attempt to serialize `openaiRaw.output` into text
|
||||
const maybeOutput = openaiRaw.output as unknown;
|
||||
if (typeof maybeOutput === "object" && maybeOutput != null) {
|
||||
try {
|
||||
outputText = JSON.stringify(maybeOutput);
|
||||
output = maybeOutput as TOutput;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
|
||||
const paths: string[] = [];
|
||||
for (const file of files) {
|
||||
const safeName = this.sanitizeFilename(file.name);
|
||||
const path = `${crypto.randomUUID()}-${safeName}`;
|
||||
const { data, error } = await this.supabase.storage
|
||||
.from(this.bucket)
|
||||
.upload(path, file, {
|
||||
contentType: file.type || "application/octet-stream",
|
||||
upsert: false,
|
||||
});
|
||||
if (error) {
|
||||
throw new Error(`Supabase upload failed: ${error.message}`);
|
||||
}
|
||||
paths.push(data.path);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
output,
|
||||
outputText,
|
||||
model: String(model),
|
||||
usage,
|
||||
responseId: String(responseId),
|
||||
conversationId: conversationId ? String(conversationId) : null,
|
||||
references: { uploadedToStorage, openaiFileIds },
|
||||
openaiRaw,
|
||||
};
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
const message = e?.message ?? "Unknown error";
|
||||
const code = message.includes("Supabase upload failed")
|
||||
? "StorageUploadFailed"
|
||||
: message.includes("OpenAI file upload failed")
|
||||
? "OpenAIFileUploadFailed"
|
||||
: "OpenAIRequestFailed";
|
||||
|
||||
return { ok: false, code, message, cause: err };
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFilesToStorage(files: File[]): Promise<string[]> {
|
||||
const paths: string[] = [];
|
||||
for (const file of files) {
|
||||
const safeName = this.sanitizeFilename(file.name);
|
||||
const path = `${crypto.randomUUID()}-${safeName}`;
|
||||
const { data, error } = await this.supabase.storage
|
||||
.from(this.bucket)
|
||||
.upload(path, file, {
|
||||
contentType: file.type || "application/octet-stream",
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Supabase upload failed: ${error.message}`);
|
||||
}
|
||||
paths.push(data.path);
|
||||
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const created = await this.openai.files.create({
|
||||
file,
|
||||
purpose: "user_data",
|
||||
});
|
||||
ids.push(created.id);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`OpenAI file upload failed: ${
|
||||
(e as Error)?.message ?? String(e)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
private async uploadFilesToOpenAI(files: File[]): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const created = await this.openai.files.create({
|
||||
file,
|
||||
purpose: "user_data",
|
||||
});
|
||||
ids.push(created.id);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`OpenAI file upload failed: ${(e as Error)?.message ?? String(e)}`,
|
||||
);
|
||||
}
|
||||
private sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"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
|
||||
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";
|
||||
import type { AIGeneratePlanInput } from "./types.ts";
|
||||
@@ -18,7 +19,7 @@ type NivelType =
|
||||
type TipoCicloType =
|
||||
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 functionName = url.pathname.split("/").pop();
|
||||
console.log(
|
||||
@@ -37,12 +38,11 @@ Deno.serve(async (req) => {
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Invalid method: ${method}`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Method not allowed" }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 405,
|
||||
},
|
||||
throw new HttpError(
|
||||
405,
|
||||
"Método no permitido.",
|
||||
"METHOD_NOT_ALLOWED",
|
||||
{ method },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,12 +54,11 @@ Deno.serve(async (req) => {
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Missing Authorization header`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Authorization header missing" }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 401,
|
||||
},
|
||||
throw new HttpError(
|
||||
401,
|
||||
"No autorizado.",
|
||||
"UNAUTHORIZED",
|
||||
{ reason: "missing_authorization_header" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,19 +69,28 @@ Deno.serve(async (req) => {
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Unsupported content type: ${contentType}`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unsupported content type" }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
},
|
||||
throw new HttpError(
|
||||
415,
|
||||
"Content-Type no soportado.",
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
{ contentType },
|
||||
);
|
||||
}
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
throw new 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
|
||||
@@ -97,7 +105,12 @@ Deno.serve(async (req) => {
|
||||
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
if (!SERVICE_ROLE_KEY) {
|
||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
|
||||
throw new HttpError(
|
||||
500,
|
||||
"Configuración del servidor incompleta.",
|
||||
"MISSING_ENV",
|
||||
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
|
||||
);
|
||||
}
|
||||
|
||||
const supabaseService = createClient<Database>(
|
||||
@@ -112,12 +125,15 @@ Deno.serve(async (req) => {
|
||||
`[${new Date().toISOString()}][${functionName}]: Validation errors:`,
|
||||
validation.errors,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({ errors: validation.errors }),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
},
|
||||
const message = validation.errors
|
||||
.map((e, i) => `(${i + 1}) ${e}`)
|
||||
.join("\n");
|
||||
|
||||
throw new HttpError(
|
||||
422,
|
||||
message,
|
||||
"VALIDATION_ERROR",
|
||||
{ errors: validation.errors },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,8 +146,23 @@ Deno.serve(async (req) => {
|
||||
.eq("id", payload.datosBasicos.estructuraPlanId)
|
||||
.single();
|
||||
if (estructuraPlanError) {
|
||||
throw new Error(
|
||||
"Error fetching estructuraPlan: " + estructuraPlanError.message,
|
||||
const maybeCode = (estructuraPlanError as { code?: string }).code;
|
||||
if (maybeCode === "PGRST116") {
|
||||
throw new HttpError(
|
||||
404,
|
||||
"No se encontró la estructura del plan.",
|
||||
"NOT_FOUND",
|
||||
{
|
||||
table: "estructuras_plan",
|
||||
id: payload.datosBasicos.estructuraPlanId,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new HttpError(
|
||||
500,
|
||||
"No se pudo obtener la estructura del plan.",
|
||||
"SUPABASE_QUERY_FAILED",
|
||||
estructuraPlanError,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,25 +203,64 @@ Deno.serve(async (req) => {
|
||||
// Use shared OpenAI service directly (no HTTP invoke)
|
||||
const svc = OpenAIService.fromEnv();
|
||||
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(
|
||||
aiStructuredPayload,
|
||||
payload.archivosAdjuntos,
|
||||
);
|
||||
if (!aiResult.ok) {
|
||||
throw new Error(
|
||||
`OpenAI call failed [${aiResult.code}]: ${aiResult.message}`,
|
||||
const status = aiResult.code === "MissingEnv" ? 500 : 502;
|
||||
throw new HttpError(
|
||||
status,
|
||||
"No se pudo generar el plan con IA.",
|
||||
"OPENAI_REQUEST_FAILED",
|
||||
aiResult,
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer parsed output; fallback to parse outputText
|
||||
const aiOutput = aiResult.output ??
|
||||
(aiResult.outputText ? JSON.parse(aiResult.outputText) : null);
|
||||
let aiOutput = aiResult.output ?? null;
|
||||
if (aiOutput == null && aiResult.outputText) {
|
||||
try {
|
||||
aiOutput = JSON.parse(aiResult.outputText);
|
||||
} catch {
|
||||
throw new HttpError(
|
||||
502,
|
||||
"La respuesta de la IA no es JSON válido.",
|
||||
"OPENAI_INVALID_JSON",
|
||||
{ outputText: aiResult.outputText },
|
||||
);
|
||||
}
|
||||
}
|
||||
const aiOutputJson: Json = aiOutput as unknown as Json;
|
||||
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
|
||||
@@ -209,7 +279,20 @@ Deno.serve(async (req) => {
|
||||
.eq("id", payload.datosBasicos.carreraId)
|
||||
.maybeSingle();
|
||||
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"] =
|
||||
@@ -257,42 +340,62 @@ Deno.serve(async (req) => {
|
||||
// TODO: interaccion con IA y cambio de estado
|
||||
|
||||
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
|
||||
const jsonResponse = {
|
||||
ok: true,
|
||||
plan,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Request processed successfully`,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify(jsonResponse),
|
||||
{
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
return sendSuccess(plan);
|
||||
} catch (error) {
|
||||
// Log full error server-side for diagnostics
|
||||
if (error instanceof Error) {
|
||||
if (error instanceof HttpError) {
|
||||
console.error(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Request handler error:`,
|
||||
error.message,
|
||||
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled 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 }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
});
|
||||
|
||||
// CASO B: Error Inesperado (Crash, Bug, Syntax Error, etc.)
|
||||
// El usuario NO debe ver esto.
|
||||
const unexpectedError = error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
|
||||
// LOG: Full stack trace y mensaje real
|
||||
console.error(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
|
||||
unexpectedError.stack || unexpectedError.message, // Esto es lo que necesitas para debuguear
|
||||
);
|
||||
|
||||
// RESPONSE: Mensaje genérico y seguro
|
||||
return sendError(
|
||||
500,
|
||||
"Ocurrió un error inesperado en el servidor.",
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -324,10 +427,7 @@ const DatosBasicosSchema: z.ZodType<AIGeneratePlanInput["datosBasicos"]> = z
|
||||
});
|
||||
|
||||
const IAConfigSchema: z.ZodType<AIGeneratePlanInput["iaConfig"]> = z.object({
|
||||
descripcionEnfoqueAcademico: z.string().min(
|
||||
10,
|
||||
"La descripción es muy corta",
|
||||
),
|
||||
descripcionEnfoqueAcademico: z.string(),
|
||||
instruccionesAdicionalesIA: z.string().optional(),
|
||||
archivosReferencia: z.array(z.string().uuid()).optional(),
|
||||
repositoriosIds: z.array(z.string().uuid()).optional(),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Configuration for private npm package dependencies
|
||||
# For more information on using private registries with Edge Functions, see:
|
||||
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"imports": {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { HttpError, sendError, sendSuccess } from "../_shared/utils.ts";
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
import type { Database, Json } from "../_shared/database.types.ts";
|
||||
|
||||
console.log("Hello from Functions!");
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const url = new URL(req.url);
|
||||
const functionName = url.pathname.split("/").pop();
|
||||
console.log(
|
||||
`[${new Date().toISOString()}][${functionName}]: Request received`,
|
||||
);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
"Configuración del servidor incompleta.",
|
||||
"MISSING_ENV",
|
||||
{
|
||||
missing: [
|
||||
!SUPABASE_URL ? "SUPABASE_URL" : null,
|
||||
!SUPABASE_ANON_KEY ? "SUPABASE_ANON_KEY" : null,
|
||||
].filter(Boolean),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// If needed for RLS-protected reads, create an anon client with user's JWT
|
||||
// Currently not used; kept here for future expansion.
|
||||
// const supabaseAnon = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
// global: {
|
||||
// headers: {
|
||||
// Authorization: authHeaderRaw,
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
if (!SERVICE_ROLE_KEY) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
"Configuración del servidor incompleta.",
|
||||
"MISSING_ENV",
|
||||
{ missing: ["SUPABASE_SERVICE_ROLE_KEY"] },
|
||||
);
|
||||
}
|
||||
|
||||
const supabaseService = createClient<Database>(
|
||||
SUPABASE_URL,
|
||||
SERVICE_ROLE_KEY,
|
||||
);
|
||||
|
||||
// INICIO DE CODIGO PARA DEBBUGGING
|
||||
console.log(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Request processed successfully`,
|
||||
);
|
||||
const { data: asignatura_debug, error: asignatura_error } =
|
||||
await supabaseService
|
||||
.from("asignaturas")
|
||||
.select("*")
|
||||
.eq("id", "9d4dda6a-488f-428a-8a07-38081592a641")
|
||||
.single();
|
||||
return sendSuccess(asignatura_debug);
|
||||
// FIN DE CODIGO PARA DEBBUGGING
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`,
|
||||
{
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
internalDetails: error.internalDetails || "N/A",
|
||||
},
|
||||
);
|
||||
|
||||
return sendError(error.status, error.message, error.code);
|
||||
}
|
||||
|
||||
const unexpectedError = error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
|
||||
console.error(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`,
|
||||
unexpectedError.stack || unexpectedError.message,
|
||||
);
|
||||
|
||||
return sendError(
|
||||
500,
|
||||
"Ocurrió un error inesperado en el servidor.",
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { OpenAIService } from "../_shared/openai-service.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
type WebhookPayload = {
|
||||
type: "INSERT" | "UPDATE" | "DELETE";
|
||||
table: string;
|
||||
@@ -11,79 +8,73 @@ type WebhookPayload = {
|
||||
record: Record<string, unknown> | null;
|
||||
old_record: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
|
||||
const SUPABASE_SERVICE_ROLE_KEY =
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
|
||||
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ??
|
||||
"";
|
||||
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? "";
|
||||
const OPENAI_BASE_URL = Deno.env.get("OPENAI_BASE_URL") ??
|
||||
"https://api.openai.com/v1";
|
||||
const ALLOWED_SCHEMA = "public";
|
||||
const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]);
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
function jsonResponse(status: number, body: Record<string, unknown>) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...corsHeaders },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function createConversationId(
|
||||
openaiService: OpenAIService,
|
||||
metadata: Record<string, string>,
|
||||
) {
|
||||
const conversation = await openaiService.createConversation(metadata);
|
||||
const conversationId = conversation?.id as string | undefined;
|
||||
|
||||
async function createConversationId(metadata: Record<string, string>) {
|
||||
const response = await fetch(`${OPENAI_BASE_URL}/conversations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ metadata }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenAI error: ${response.status} ${errorText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const conversationId = data?.id as string | undefined;
|
||||
if (!conversationId) {
|
||||
throw new Error("OpenAI response missing conversation id");
|
||||
}
|
||||
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method !== "POST") {
|
||||
return jsonResponse(405, { error: "Method not allowed" });
|
||||
}
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||
return jsonResponse(500, { error: "Supabase env vars missing" });
|
||||
}
|
||||
|
||||
const openaiService = OpenAIService.fromEnv();
|
||||
if (!(openaiService instanceof OpenAIService)) {
|
||||
return jsonResponse(500, { error: openaiService.message });
|
||||
if (!OPENAI_API_KEY) {
|
||||
return jsonResponse(500, { error: "OPENAI_API_KEY missing" });
|
||||
}
|
||||
|
||||
let payload: WebhookPayload;
|
||||
try {
|
||||
payload = await req.json();
|
||||
} catch {
|
||||
return jsonResponse(400, { error: "Invalid JSON body" });
|
||||
}
|
||||
|
||||
const { type, table, schema, record } = payload;
|
||||
|
||||
if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) {
|
||||
return jsonResponse(400, { error: "Table not allowed" });
|
||||
}
|
||||
|
||||
if (type !== "INSERT") {
|
||||
return jsonResponse(200, { status: "ignored", reason: "event_type" });
|
||||
}
|
||||
|
||||
if (!record || typeof record !== "object") {
|
||||
return jsonResponse(400, { error: "Missing record" });
|
||||
}
|
||||
|
||||
const recordId = record["id"] as string | number | undefined;
|
||||
if (!recordId) {
|
||||
return jsonResponse(400, { error: "Record id missing" });
|
||||
}
|
||||
|
||||
const existingConversationId = record["conversation_id"] as
|
||||
| string
|
||||
| null
|
||||
@@ -94,26 +85,22 @@ Deno.serve(async (req) => {
|
||||
conversation_id: existingConversationId,
|
||||
});
|
||||
}
|
||||
|
||||
let conversationId: string;
|
||||
try {
|
||||
conversationId = await createConversationId(openaiService, {
|
||||
conversationId = await createConversationId({
|
||||
table,
|
||||
record_id: String(recordId),
|
||||
});
|
||||
} catch (error) {
|
||||
return jsonResponse(502, { error: (error as Error).message });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from(table)
|
||||
.update({ conversation_id: conversationId })
|
||||
.eq("id", recordId);
|
||||
|
||||
if (error) {
|
||||
return jsonResponse(500, { error: error.message });
|
||||
}
|
||||
|
||||
return jsonResponse(200, {
|
||||
status: "updated",
|
||||
table,
|
||||
|
||||
@@ -25,9 +25,6 @@ function mustEnv() {
|
||||
if (!SUPABASE_ANON_KEY) throw new Error("SUPABASE_ANON_KEY is required");
|
||||
}
|
||||
|
||||
async function getAuthedClient(): Promise<
|
||||
{ client: SupabaseClient; accessToken: string }
|
||||
> {
|
||||
async function getAuthedClient(): Promise<
|
||||
{ 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";
|
||||
|
||||
|
||||
@@ -159,7 +166,8 @@ CREATE TYPE "public"."tipo_cambio" AS ENUM (
|
||||
'ACTUALIZACION_MAPA',
|
||||
'TRANSICION_ESTADO',
|
||||
'OTRO',
|
||||
'CREACION'
|
||||
'CREACION',
|
||||
'ACTUALIZACION'
|
||||
);
|
||||
|
||||
|
||||
@@ -230,6 +238,172 @@ CREATE TYPE "public"."tipo_origen" AS ENUM (
|
||||
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"
|
||||
LANGUAGE "plpgsql"
|
||||
AS $$
|
||||
@@ -246,7 +420,7 @@ ALTER FUNCTION "public"."set_actualizado_en"() OWNER TO "postgres";
|
||||
CREATE OR REPLACE FUNCTION "public"."unaccent_immutable"("text") RETURNS "text"
|
||||
LANGUAGE "sql" IMMUTABLE STRICT PARALLEL SAFE
|
||||
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,
|
||||
"plan_estudio_id" "uuid" NOT NULL,
|
||||
"estructura_id" "uuid",
|
||||
"facultad_propietaria_id" "uuid",
|
||||
"codigo" "text",
|
||||
"nombre" "text" 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",
|
||||
"creado_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_creditos_check" CHECK (("creditos" >= (0)::numeric)),
|
||||
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",
|
||||
"valor_anterior" "jsonb",
|
||||
"valor_nuevo" "jsonb",
|
||||
"interaccion_ia_id" "uuid"
|
||||
"response_id" "text"
|
||||
);
|
||||
|
||||
|
||||
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" (
|
||||
"id" "uuid" DEFAULT "gen_random_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,
|
||||
"actualizado_en" timestamp with time zone DEFAULT "now"() NOT NULL,
|
||||
"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))
|
||||
);
|
||||
|
||||
@@ -532,6 +712,10 @@ CREATE TABLE IF NOT EXISTS "public"."planes_estudio" (
|
||||
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
|
||||
SELECT "plan"."id" AS "plan_estudio_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"
|
||||
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"();
|
||||
|
||||
|
||||
@@ -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"
|
||||
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"
|
||||
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 "authenticated";
|
||||
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