From 2ff587d63969b2c9d82770f1271392588be6c6f4 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Thu, 12 Feb 2026 15:48:04 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20opcion=20de=20update=20a=20asignaturas?= =?UTF-8?q?=20ya=20creadas=20para=20ponerles=20la=20informaci=C3=B3n=20gen?= =?UTF-8?q?erada=20con=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created a new enum type "estado_asignatura" with values 'borrador', 'revisada', 'aprobada', and 'generando'. - Added a new column "estado" to the "asignaturas" table, defaulting to 'generando'. - Implemented a trigger function to log changes in the "planes_estudio" table, capturing inserts, updates, and deletes. - Updated the logging mechanism to track changes in various fields, including JSONB data. - Removed the "asignaturas" table from the supabase_realtime publication. - Added a script for generating TypeScript types from the local Supabase database. --- database-types.ts | 1318 +++++++++++++++++ scripts/generacion_de_tipos_de_supabase.txt | 2 + supabase/functions/_shared/database.types.ts | 59 +- .../functions/ai-generate-subject/index.ts | 357 ++++- ...60212214439_columna_estado_asignaturas.sql | 171 +++ supabase/snippets/Untitled query 470.sql | 1 + 6 files changed, 1877 insertions(+), 31 deletions(-) create mode 100644 database-types.ts create mode 100644 scripts/generacion_de_tipos_de_supabase.txt create mode 100644 supabase/migrations/20260212214439_columna_estado_asignaturas.sql create mode 100644 supabase/snippets/Untitled query 470.sql diff --git a/database-types.ts b/database-types.ts new file mode 100644 index 0000000..f7f6bb9 --- /dev/null +++ b/database-types.ts @@ -0,0 +1,1318 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } + public: { + Tables: { + archivos: { + Row: { + bytes: number | null + id: string + mime_type: string | null + nombre: string + notas: string | null + openai_file_id: string | null + ruta_storage: string + subido_en: string + subido_por: string | null + temporal: boolean + } + Insert: { + bytes?: number | null + id?: string + mime_type?: string | null + nombre: string + notas?: string | null + openai_file_id?: string | null + ruta_storage: string + subido_en?: string + subido_por?: string | null + temporal?: boolean + } + Update: { + bytes?: number | null + id?: string + mime_type?: string | null + nombre?: string + notas?: string | null + openai_file_id?: string | null + ruta_storage?: string + subido_en?: string + subido_por?: string | null + temporal?: boolean + } + Relationships: [ + { + foreignKeyName: "archivos_subido_por_fkey" + columns: ["subido_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + asignaturas: { + Row: { + actualizado_en: string + actualizado_por: string | null + asignatura_hash: string | null + codigo: string | null + contenido_tematico: Json + conversation_id: string | null + creado_en: string + creado_por: string | null + creditos: number + datos: Json + estado: Database["public"]["Enums"]["estado_asignatura"] + estructura_id: string | null + horas_academicas: number | null + horas_independientes: number | null + id: string + linea_plan_id: string | null + meta_origen: Json + nombre: string + numero_ciclo: number | null + orden_celda: number | null + plan_estudio_id: string + tipo: Database["public"]["Enums"]["tipo_asignatura"] + tipo_origen: Database["public"]["Enums"]["tipo_origen"] | null + } + Insert: { + actualizado_en?: string + actualizado_por?: string | null + asignatura_hash?: string | null + codigo?: string | null + contenido_tematico?: Json + conversation_id?: string | null + creado_en?: string + creado_por?: string | null + creditos: number + datos?: Json + estado?: Database["public"]["Enums"]["estado_asignatura"] + estructura_id?: string | null + horas_academicas?: number | null + horas_independientes?: number | null + id?: string + linea_plan_id?: string | null + meta_origen?: Json + nombre: string + numero_ciclo?: number | null + orden_celda?: number | null + plan_estudio_id: string + tipo?: Database["public"]["Enums"]["tipo_asignatura"] + tipo_origen?: Database["public"]["Enums"]["tipo_origen"] | null + } + Update: { + actualizado_en?: string + actualizado_por?: string | null + asignatura_hash?: string | null + codigo?: string | null + contenido_tematico?: Json + conversation_id?: string | null + creado_en?: string + creado_por?: string | null + creditos?: number + datos?: Json + estado?: Database["public"]["Enums"]["estado_asignatura"] + estructura_id?: string | null + horas_academicas?: number | null + horas_independientes?: number | null + id?: string + linea_plan_id?: string | null + meta_origen?: Json + nombre?: string + numero_ciclo?: number | null + orden_celda?: number | null + plan_estudio_id?: string + tipo?: Database["public"]["Enums"]["tipo_asignatura"] + tipo_origen?: Database["public"]["Enums"]["tipo_origen"] | null + } + Relationships: [ + { + foreignKeyName: "asignaturas_actualizado_por_fkey" + columns: ["actualizado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + { + foreignKeyName: "asignaturas_creado_por_fkey" + columns: ["creado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + { + foreignKeyName: "asignaturas_estructura_id_fkey" + columns: ["estructura_id"] + isOneToOne: false + referencedRelation: "estructuras_asignatura" + referencedColumns: ["id"] + }, + { + foreignKeyName: "asignaturas_linea_plan_fk_compuesta" + columns: ["linea_plan_id", "plan_estudio_id"] + isOneToOne: false + referencedRelation: "lineas_plan" + referencedColumns: ["id", "plan_estudio_id"] + }, + { + foreignKeyName: "asignaturas_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "planes_estudio" + referencedColumns: ["id"] + }, + { + foreignKeyName: "asignaturas_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "plantilla_plan" + referencedColumns: ["plan_estudio_id"] + }, + ] + } + bibliografia_asignatura: { + Row: { + actualizado_en: string + asignatura_id: string + biblioteca_item_id: string | null + cita: string + creado_en: string + creado_por: string | null + id: string + tipo: Database["public"]["Enums"]["tipo_bibliografia"] + tipo_fuente: Database["public"]["Enums"]["tipo_fuente_bibliografia"] + } + Insert: { + actualizado_en?: string + asignatura_id: string + biblioteca_item_id?: string | null + cita: string + creado_en?: string + creado_por?: string | null + id?: string + tipo: Database["public"]["Enums"]["tipo_bibliografia"] + tipo_fuente?: Database["public"]["Enums"]["tipo_fuente_bibliografia"] + } + Update: { + actualizado_en?: string + asignatura_id?: string + biblioteca_item_id?: string | null + cita?: string + creado_en?: string + creado_por?: string | null + id?: string + tipo?: Database["public"]["Enums"]["tipo_bibliografia"] + tipo_fuente?: Database["public"]["Enums"]["tipo_fuente_bibliografia"] + } + Relationships: [ + { + foreignKeyName: "bibliografia_asignatura_asignatura_id_fkey" + columns: ["asignatura_id"] + isOneToOne: false + referencedRelation: "asignaturas" + referencedColumns: ["id"] + }, + { + foreignKeyName: "bibliografia_asignatura_creado_por_fkey" + columns: ["creado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + cambios_asignatura: { + Row: { + asignatura_id: string + cambiado_en: string + cambiado_por: string | null + campo: string | null + fuente: Database["public"]["Enums"]["fuente_cambio"] | null + id: string + interaccion_ia_id: string | null + tipo: Database["public"]["Enums"]["tipo_cambio"] + valor_anterior: Json | null + valor_nuevo: Json | null + } + Insert: { + asignatura_id: string + cambiado_en?: string + cambiado_por?: string | null + campo?: string | null + fuente?: Database["public"]["Enums"]["fuente_cambio"] | null + id?: string + interaccion_ia_id?: string | null + tipo: Database["public"]["Enums"]["tipo_cambio"] + valor_anterior?: Json | null + valor_nuevo?: Json | null + } + Update: { + asignatura_id?: string + cambiado_en?: string + cambiado_por?: string | null + campo?: string | null + fuente?: Database["public"]["Enums"]["fuente_cambio"] | null + id?: string + interaccion_ia_id?: string | null + tipo?: Database["public"]["Enums"]["tipo_cambio"] + valor_anterior?: Json | null + valor_nuevo?: Json | null + } + Relationships: [ + { + foreignKeyName: "cambios_asignatura_asignatura_id_fkey" + columns: ["asignatura_id"] + isOneToOne: false + referencedRelation: "asignaturas" + referencedColumns: ["id"] + }, + { + foreignKeyName: "cambios_asignatura_cambiado_por_fkey" + columns: ["cambiado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + cambios_plan: { + Row: { + cambiado_en: string + cambiado_por: string | null + campo: string | null + id: string + plan_estudio_id: string + response_id: string | null + tipo: Database["public"]["Enums"]["tipo_cambio"] + valor_anterior: Json | null + valor_nuevo: Json | null + } + Insert: { + cambiado_en?: string + cambiado_por?: string | null + campo?: string | null + id?: string + plan_estudio_id: string + response_id?: string | null + tipo: Database["public"]["Enums"]["tipo_cambio"] + valor_anterior?: Json | null + valor_nuevo?: Json | null + } + Update: { + cambiado_en?: string + cambiado_por?: string | null + campo?: string | null + id?: string + plan_estudio_id?: string + response_id?: string | null + tipo?: Database["public"]["Enums"]["tipo_cambio"] + valor_anterior?: Json | null + valor_nuevo?: Json | null + } + Relationships: [ + { + foreignKeyName: "cambios_plan_cambiado_por_fkey" + columns: ["cambiado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + carreras: { + Row: { + activa: boolean + actualizado_en: string + clave_sep: string | null + creado_en: string + facultad_id: string + id: string + nombre: string + nombre_corto: string | null + } + Insert: { + activa?: boolean + actualizado_en?: string + clave_sep?: string | null + creado_en?: string + facultad_id: string + id?: string + nombre: string + nombre_corto?: string | null + } + Update: { + activa?: boolean + actualizado_en?: string + clave_sep?: string | null + creado_en?: string + facultad_id?: string + id?: string + nombre?: string + nombre_corto?: string | null + } + Relationships: [ + { + foreignKeyName: "carreras_facultad_id_fkey" + columns: ["facultad_id"] + isOneToOne: false + referencedRelation: "facultades" + referencedColumns: ["id"] + }, + ] + } + estados_plan: { + Row: { + clave: string + es_final: boolean + etiqueta: string + id: string + orden: number + } + Insert: { + clave: string + es_final?: boolean + etiqueta: string + id?: string + orden?: number + } + Update: { + clave?: string + es_final?: boolean + etiqueta?: string + id?: string + orden?: number + } + Relationships: [] + } + estructuras_asignatura: { + Row: { + actualizado_en: string + creado_en: string + definicion: Json + id: string + nombre: string + version: string | null + } + Insert: { + actualizado_en?: string + creado_en?: string + definicion?: Json + id?: string + nombre: string + version?: string | null + } + Update: { + actualizado_en?: string + creado_en?: string + definicion?: Json + id?: string + nombre?: string + version?: string | null + } + Relationships: [] + } + estructuras_plan: { + Row: { + actualizado_en: string + creado_en: string + definicion: Json + id: string + nombre: string + template_id: string | null + tipo: Database["public"]["Enums"]["tipo_estructura_plan"] + } + Insert: { + actualizado_en?: string + creado_en?: string + definicion?: Json + id?: string + nombre: string + template_id?: string | null + tipo: Database["public"]["Enums"]["tipo_estructura_plan"] + } + Update: { + actualizado_en?: string + creado_en?: string + definicion?: Json + id?: string + nombre?: string + template_id?: string | null + tipo?: Database["public"]["Enums"]["tipo_estructura_plan"] + } + Relationships: [] + } + facultades: { + Row: { + actualizado_en: string + color: string | null + creado_en: string + icono: string | null + id: string + nombre: string + nombre_corto: string | null + } + Insert: { + actualizado_en?: string + color?: string | null + creado_en?: string + icono?: string | null + id?: string + nombre: string + nombre_corto?: string | null + } + Update: { + actualizado_en?: string + color?: string | null + creado_en?: string + icono?: string | null + id?: string + nombre?: string + nombre_corto?: string | null + } + Relationships: [] + } + interacciones_ia: { + Row: { + aceptada: boolean + asignatura_id: string | null + conversacion_id: string | null + creado_en: string + id: string + ids_archivos: Json + ids_vector_store: Json + modelo: string | null + plan_estudio_id: string | null + prompt: Json + respuesta: Json + rutas_storage: Json + temperatura: number | null + tipo: Database["public"]["Enums"]["tipo_interaccion_ia"] + usuario_id: string | null + } + Insert: { + aceptada?: boolean + asignatura_id?: string | null + conversacion_id?: string | null + creado_en?: string + id?: string + ids_archivos?: Json + ids_vector_store?: Json + modelo?: string | null + plan_estudio_id?: string | null + prompt?: Json + respuesta?: Json + rutas_storage?: Json + temperatura?: number | null + tipo: Database["public"]["Enums"]["tipo_interaccion_ia"] + usuario_id?: string | null + } + Update: { + aceptada?: boolean + asignatura_id?: string | null + conversacion_id?: string | null + creado_en?: string + id?: string + ids_archivos?: Json + ids_vector_store?: Json + modelo?: string | null + plan_estudio_id?: string | null + prompt?: Json + respuesta?: Json + rutas_storage?: Json + temperatura?: number | null + tipo?: Database["public"]["Enums"]["tipo_interaccion_ia"] + usuario_id?: string | null + } + Relationships: [ + { + foreignKeyName: "interacciones_ia_asignatura_id_fkey" + columns: ["asignatura_id"] + isOneToOne: false + referencedRelation: "asignaturas" + referencedColumns: ["id"] + }, + { + foreignKeyName: "interacciones_ia_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "planes_estudio" + referencedColumns: ["id"] + }, + { + foreignKeyName: "interacciones_ia_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "plantilla_plan" + referencedColumns: ["plan_estudio_id"] + }, + { + foreignKeyName: "interacciones_ia_usuario_id_fkey" + columns: ["usuario_id"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + lineas_plan: { + Row: { + actualizado_en: string + area: string | null + creado_en: string + id: string + nombre: string + orden: number + plan_estudio_id: string + } + Insert: { + actualizado_en?: string + area?: string | null + creado_en?: string + id?: string + nombre: string + orden?: number + plan_estudio_id: string + } + Update: { + actualizado_en?: string + area?: string | null + creado_en?: string + id?: string + nombre?: string + orden?: number + plan_estudio_id?: string + } + Relationships: [ + { + foreignKeyName: "lineas_plan_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "planes_estudio" + referencedColumns: ["id"] + }, + { + foreignKeyName: "lineas_plan_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "plantilla_plan" + referencedColumns: ["plan_estudio_id"] + }, + ] + } + notificaciones: { + Row: { + creado_en: string + id: string + leida: boolean + leida_en: string | null + payload: Json + tipo: Database["public"]["Enums"]["tipo_notificacion"] + usuario_id: string + } + Insert: { + creado_en?: string + id?: string + leida?: boolean + leida_en?: string | null + payload?: Json + tipo: Database["public"]["Enums"]["tipo_notificacion"] + usuario_id: string + } + Update: { + creado_en?: string + id?: string + leida?: boolean + leida_en?: string | null + payload?: Json + tipo?: Database["public"]["Enums"]["tipo_notificacion"] + usuario_id?: string + } + Relationships: [ + { + foreignKeyName: "notificaciones_usuario_id_fkey" + columns: ["usuario_id"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + planes_estudio: { + Row: { + activo: boolean + actualizado_en: string + actualizado_por: string | null + carrera_id: string + conversation_id: string | null + creado_en: string + creado_por: string | null + datos: Json + estado_actual_id: string | null + estructura_id: string + id: string + meta_origen: Json + nivel: Database["public"]["Enums"]["nivel_plan_estudio"] + nombre: string + nombre_search: string | null + numero_ciclos: number + plan_hash: string | null + tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"] + tipo_origen: Database["public"]["Enums"]["tipo_origen"] | null + } + Insert: { + activo?: boolean + actualizado_en?: string + actualizado_por?: string | null + carrera_id: string + conversation_id?: string | null + creado_en?: string + creado_por?: string | null + datos?: Json + estado_actual_id?: string | null + estructura_id: string + id?: string + meta_origen?: Json + nivel: Database["public"]["Enums"]["nivel_plan_estudio"] + nombre: string + nombre_search?: string | null + numero_ciclos: number + plan_hash?: string | null + tipo_ciclo: Database["public"]["Enums"]["tipo_ciclo"] + tipo_origen?: Database["public"]["Enums"]["tipo_origen"] | null + } + Update: { + activo?: boolean + actualizado_en?: string + actualizado_por?: string | null + carrera_id?: string + conversation_id?: string | null + creado_en?: string + creado_por?: string | null + datos?: Json + estado_actual_id?: string | null + estructura_id?: string + id?: string + meta_origen?: Json + nivel?: Database["public"]["Enums"]["nivel_plan_estudio"] + nombre?: string + nombre_search?: string | null + numero_ciclos?: number + plan_hash?: string | null + tipo_ciclo?: Database["public"]["Enums"]["tipo_ciclo"] + tipo_origen?: Database["public"]["Enums"]["tipo_origen"] | null + } + Relationships: [ + { + foreignKeyName: "planes_estudio_actualizado_por_fkey" + columns: ["actualizado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + { + foreignKeyName: "planes_estudio_carrera_id_fkey" + columns: ["carrera_id"] + isOneToOne: false + referencedRelation: "carreras" + referencedColumns: ["id"] + }, + { + foreignKeyName: "planes_estudio_creado_por_fkey" + columns: ["creado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + { + foreignKeyName: "planes_estudio_estado_actual_id_fkey" + columns: ["estado_actual_id"] + isOneToOne: false + referencedRelation: "estados_plan" + referencedColumns: ["id"] + }, + { + foreignKeyName: "planes_estudio_estructura_id_fkey" + columns: ["estructura_id"] + isOneToOne: false + referencedRelation: "estructuras_plan" + referencedColumns: ["id"] + }, + { + foreignKeyName: "planes_estudio_estructura_id_fkey" + columns: ["estructura_id"] + isOneToOne: false + referencedRelation: "plantilla_plan" + referencedColumns: ["estructura_id"] + }, + ] + } + responsables_asignatura: { + Row: { + asignatura_id: string + creado_en: string + id: string + rol: Database["public"]["Enums"]["rol_responsable_asignatura"] + usuario_id: string + } + Insert: { + asignatura_id: string + creado_en?: string + id?: string + rol?: Database["public"]["Enums"]["rol_responsable_asignatura"] + usuario_id: string + } + Update: { + asignatura_id?: string + creado_en?: string + id?: string + rol?: Database["public"]["Enums"]["rol_responsable_asignatura"] + usuario_id?: string + } + Relationships: [ + { + foreignKeyName: "responsables_asignatura_asignatura_id_fkey" + columns: ["asignatura_id"] + isOneToOne: false + referencedRelation: "asignaturas" + referencedColumns: ["id"] + }, + { + foreignKeyName: "responsables_asignatura_usuario_id_fkey" + columns: ["usuario_id"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + roles: { + Row: { + clave: string + descripcion: string | null + id: string + nombre: string + } + Insert: { + clave: string + descripcion?: string | null + id?: string + nombre: string + } + Update: { + clave?: string + descripcion?: string | null + id?: string + nombre?: string + } + Relationships: [] + } + tareas_revision: { + Row: { + asignado_a: string + completado_en: string | null + creado_en: string + estado_id: string | null + estatus: Database["public"]["Enums"]["estado_tarea_revision"] + fecha_limite: string | null + id: string + plan_estudio_id: string + rol_id: string | null + } + Insert: { + asignado_a: string + completado_en?: string | null + creado_en?: string + estado_id?: string | null + estatus?: Database["public"]["Enums"]["estado_tarea_revision"] + fecha_limite?: string | null + id?: string + plan_estudio_id: string + rol_id?: string | null + } + Update: { + asignado_a?: string + completado_en?: string | null + creado_en?: string + estado_id?: string | null + estatus?: Database["public"]["Enums"]["estado_tarea_revision"] + fecha_limite?: string | null + id?: string + plan_estudio_id?: string + rol_id?: string | null + } + Relationships: [ + { + foreignKeyName: "tareas_revision_asignado_a_fkey" + columns: ["asignado_a"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tareas_revision_estado_id_fkey" + columns: ["estado_id"] + isOneToOne: false + referencedRelation: "estados_plan" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tareas_revision_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "planes_estudio" + referencedColumns: ["id"] + }, + { + foreignKeyName: "tareas_revision_plan_estudio_id_fkey" + columns: ["plan_estudio_id"] + isOneToOne: false + referencedRelation: "plantilla_plan" + referencedColumns: ["plan_estudio_id"] + }, + { + foreignKeyName: "tareas_revision_rol_id_fkey" + columns: ["rol_id"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["id"] + }, + ] + } + transiciones_estado_plan: { + Row: { + creado_en: string + desde_estado_id: string + hacia_estado_id: string + id: string + rol_permitido_id: string + } + Insert: { + creado_en?: string + desde_estado_id: string + hacia_estado_id: string + id?: string + rol_permitido_id: string + } + Update: { + creado_en?: string + desde_estado_id?: string + hacia_estado_id?: string + id?: string + rol_permitido_id?: string + } + Relationships: [ + { + foreignKeyName: "transiciones_estado_plan_desde_estado_id_fkey" + columns: ["desde_estado_id"] + isOneToOne: false + referencedRelation: "estados_plan" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transiciones_estado_plan_hacia_estado_id_fkey" + columns: ["hacia_estado_id"] + isOneToOne: false + referencedRelation: "estados_plan" + referencedColumns: ["id"] + }, + { + foreignKeyName: "transiciones_estado_plan_rol_permitido_id_fkey" + columns: ["rol_permitido_id"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["id"] + }, + ] + } + usuarios_app: { + Row: { + actualizado_en: string + creado_en: string + email: string | null + externo: boolean + id: string + nombre_completo: string | null + } + Insert: { + actualizado_en?: string + creado_en?: string + email?: string | null + externo?: boolean + id: string + nombre_completo?: string | null + } + Update: { + actualizado_en?: string + creado_en?: string + email?: string | null + externo?: boolean + id?: string + nombre_completo?: string | null + } + Relationships: [] + } + usuarios_roles: { + Row: { + carrera_id: string | null + creado_en: string + facultad_id: string | null + id: string + rol_id: string + usuario_id: string + } + Insert: { + carrera_id?: string | null + creado_en?: string + facultad_id?: string | null + id?: string + rol_id: string + usuario_id: string + } + Update: { + carrera_id?: string | null + creado_en?: string + facultad_id?: string | null + id?: string + rol_id?: string + usuario_id?: string + } + Relationships: [ + { + foreignKeyName: "usuarios_roles_carrera_id_fkey" + columns: ["carrera_id"] + isOneToOne: false + referencedRelation: "carreras" + referencedColumns: ["id"] + }, + { + foreignKeyName: "usuarios_roles_facultad_id_fkey" + columns: ["facultad_id"] + isOneToOne: false + referencedRelation: "facultades" + referencedColumns: ["id"] + }, + { + foreignKeyName: "usuarios_roles_rol_id_fkey" + columns: ["rol_id"] + isOneToOne: false + referencedRelation: "roles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "usuarios_roles_usuario_id_fkey" + columns: ["usuario_id"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + vector_stores: { + Row: { + creado_en: string + creado_por: string | null + id: string + nombre: string + openai_vector_id: string | null + } + Insert: { + creado_en?: string + creado_por?: string | null + id?: string + nombre: string + openai_vector_id?: string | null + } + Update: { + creado_en?: string + creado_por?: string | null + id?: string + nombre?: string + openai_vector_id?: string | null + } + Relationships: [ + { + foreignKeyName: "vector_stores_creado_por_fkey" + columns: ["creado_por"] + isOneToOne: false + referencedRelation: "usuarios_app" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + plantilla_plan: { + Row: { + estructura_id: string | null + plan_estudio_id: string | null + template_id: string | null + } + Relationships: [] + } + } + Functions: { + unaccent: { Args: { "": string }; Returns: string } + unaccent_immutable: { Args: { "": string }; Returns: string } + } + Enums: { + estado_asignatura: "borrador" | "revisada" | "aprobada" | "generando" + estado_tarea_revision: "PENDIENTE" | "COMPLETADA" | "OMITIDA" + fuente_cambio: "HUMANO" | "IA" + nivel_plan_estudio: + | "Licenciatura" + | "Maestría" + | "Doctorado" + | "Especialidad" + | "Diplomado" + | "Otro" + puesto_tipo: + | "vicerrector" + | "director_facultad" + | "secretario_academico" + | "jefe_carrera" + | "profesor" + | "lci" + rol_responsable_asignatura: "PROFESOR_RESPONSABLE" | "COAUTOR" | "REVISOR" + tipo_asignatura: "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA" + tipo_bibliografia: "BASICA" | "COMPLEMENTARIA" + tipo_cambio: + | "ACTUALIZACION_CAMPO" + | "ACTUALIZACION_MAPA" + | "TRANSICION_ESTADO" + | "OTRO" + | "CREACION" + | "ACTUALIZACION" + tipo_ciclo: "Semestre" | "Cuatrimestre" | "Trimestre" | "Otro" + tipo_estructura_plan: "CURRICULAR" | "NO_CURRICULAR" + tipo_fuente_bibliografia: "MANUAL" | "BIBLIOTECA" + tipo_interaccion_ia: "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA" + tipo_notificacion: + | "PLAN_ASIGNADO" + | "ESTADO_CAMBIADO" + | "TAREA_ASIGNADA" + | "COMENTARIO" + | "OTRA" + tipo_origen: + | "MANUAL" + | "IA" + | "CLONADO_INTERNO" + | "CLONADO_TRADICIONAL" + | "OTRO" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: { + estado_asignatura: ["borrador", "revisada", "aprobada", "generando"], + estado_tarea_revision: ["PENDIENTE", "COMPLETADA", "OMITIDA"], + fuente_cambio: ["HUMANO", "IA"], + nivel_plan_estudio: [ + "Licenciatura", + "Maestría", + "Doctorado", + "Especialidad", + "Diplomado", + "Otro", + ], + puesto_tipo: [ + "vicerrector", + "director_facultad", + "secretario_academico", + "jefe_carrera", + "profesor", + "lci", + ], + rol_responsable_asignatura: [ + "PROFESOR_RESPONSABLE", + "COAUTOR", + "REVISOR", + ], + tipo_asignatura: ["OBLIGATORIA", "OPTATIVA", "TRONCAL", "OTRA"], + tipo_bibliografia: ["BASICA", "COMPLEMENTARIA"], + tipo_cambio: [ + "ACTUALIZACION_CAMPO", + "ACTUALIZACION_MAPA", + "TRANSICION_ESTADO", + "OTRO", + "CREACION", + "ACTUALIZACION", + ], + tipo_ciclo: ["Semestre", "Cuatrimestre", "Trimestre", "Otro"], + tipo_estructura_plan: ["CURRICULAR", "NO_CURRICULAR"], + tipo_fuente_bibliografia: ["MANUAL", "BIBLIOTECA"], + tipo_interaccion_ia: ["GENERAR", "MEJORAR_SECCION", "CHAT", "OTRA"], + tipo_notificacion: [ + "PLAN_ASIGNADO", + "ESTADO_CAMBIADO", + "TAREA_ASIGNADA", + "COMENTARIO", + "OTRA", + ], + tipo_origen: [ + "MANUAL", + "IA", + "CLONADO_INTERNO", + "CLONADO_TRADICIONAL", + "OTRO", + ], + }, + }, +} as const + diff --git a/scripts/generacion_de_tipos_de_supabase.txt b/scripts/generacion_de_tipos_de_supabase.txt new file mode 100644 index 0000000..e98678d --- /dev/null +++ b/scripts/generacion_de_tipos_de_supabase.txt @@ -0,0 +1,2 @@ +Usar --local para generar tipos de la BDD local, y --linked para generar tipos de la BDD remota. +cmd /c "dx supabase gen types typescript --local > database-types.ts" \ No newline at end of file diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts index 80007b3..4e5b0a0 100644 --- a/supabase/functions/_shared/database.types.ts +++ b/supabase/functions/_shared/database.types.ts @@ -4,14 +4,9 @@ | boolean | null | { [key: string]: Json | undefined } - | Array; + | Json[]; export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "12.2.3 (519615d)"; - }; graphql_public: { Tables: { [_ in never]: never; @@ -98,6 +93,7 @@ export type Database = { creado_por: string | null; creditos: number; datos: Json; + estado: Database["public"]["Enums"]["estado_asignatura"]; estructura_id: string | null; horas_academicas: number | null; horas_independientes: number | null; @@ -124,6 +120,7 @@ export type Database = { creado_por?: string | null; creditos: number; datos?: Json; + estado?: Database["public"]["Enums"]["estado_asignatura"]; estructura_id?: string | null; horas_academicas?: number | null; horas_independientes?: number | null; @@ -150,6 +147,7 @@ export type Database = { creado_por?: string | null; creditos?: number; datos?: Json; + estado?: Database["public"]["Enums"]["estado_asignatura"]; estructura_id?: string | null; horas_academicas?: number | null; horas_independientes?: number | null; @@ -815,20 +813,18 @@ export type Database = { asignatura_id: string; creado_en?: string; id?: string; - rol?: - Database["public"]["Enums"][ - "rol_responsable_asignatura" - ]; + rol?: Database["public"]["Enums"][ + "rol_responsable_asignatura" + ]; usuario_id: string; }; Update: { asignatura_id?: string; creado_en?: string; id?: string; - rol?: - Database["public"]["Enums"][ - "rol_responsable_asignatura" - ]; + rol?: Database["public"]["Enums"][ + "rol_responsable_asignatura" + ]; usuario_id?: string; }; Relationships: [ @@ -1126,6 +1122,11 @@ export type Database = { unaccent_immutable: { Args: { "": string }; Returns: string }; }; Enums: { + estado_asignatura: + | "borrador" + | "revisada" + | "aprobada" + | "generando"; estado_tarea_revision: "PENDIENTE" | "COMPLETADA" | "OMITIDA"; fuente_cambio: "HUMANO" | "IA"; nivel_plan_estudio: @@ -1233,15 +1234,13 @@ export type TablesInsert< | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals; - } - ? keyof DatabaseWithoutInternals[ + } ? keyof DatabaseWithoutInternals[ DefaultSchemaTableNameOrOptions["schema"] ]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals; -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ +} ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ "Tables" ][TableName] extends { Insert: infer I; @@ -1260,15 +1259,13 @@ export type TablesUpdate< | { schema: keyof DatabaseWithoutInternals }, TableName extends DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals; - } - ? keyof DatabaseWithoutInternals[ + } ? keyof DatabaseWithoutInternals[ DefaultSchemaTableNameOrOptions["schema"] ]["Tables"] : never = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals; -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ +} ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]][ "Tables" ][TableName] extends { Update: infer U; @@ -1287,15 +1284,13 @@ export type Enums< | { schema: keyof DatabaseWithoutInternals }, EnumName extends DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals; - } - ? keyof DatabaseWithoutInternals[ + } ? keyof DatabaseWithoutInternals[ DefaultSchemaEnumNameOrOptions["schema"] ]["Enums"] : never = never, > = DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals; -} - ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]][ +} ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]][ "Enums" ][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] @@ -1308,15 +1303,13 @@ export type CompositeTypes< | { schema: keyof DatabaseWithoutInternals }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals; - } - ? keyof DatabaseWithoutInternals[ + } ? keyof DatabaseWithoutInternals[ PublicCompositeTypeNameOrOptions["schema"] ]["CompositeTypes"] : never = never, > = PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals; -} - ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]][ +} ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]][ "CompositeTypes" ][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends @@ -1330,6 +1323,12 @@ export const Constants = { }, public: { Enums: { + estado_asignatura: [ + "borrador", + "revisada", + "aprobada", + "generando", + ], estado_tarea_revision: ["PENDIENTE", "COMPLETADA", "OMITIDA"], fuente_cambio: ["HUMANO", "IA"], nivel_plan_estudio: [ diff --git a/supabase/functions/ai-generate-subject/index.ts b/supabase/functions/ai-generate-subject/index.ts index 9045854..7da3162 100644 --- a/supabase/functions/ai-generate-subject/index.ts +++ b/supabase/functions/ai-generate-subject/index.ts @@ -9,6 +9,66 @@ import { type StructuredResponseOptions, } from "../_shared/openai-service.ts"; +/** + * JSON input signature (when `Content-Type: application/json`) + * + * Required: + * - `id` (uuid) - corresponde a la columna `id` de `asignaturas` + * + * Optional patch fields (any subset is allowed): + * - `nombre` (string) + * - `codigo` (string | null) + * - `tipo` (string | null) // must match DB enum values + * - `creditos` (number) + * - `horas_academicas` (number | null) + * - `horas_independientes` (number | null) + * - `numero_ciclo` (number | null) + * - `estructura_id` (uuid | null) + * - `plan_estudio_id` (uuid) + * - `linea_plan_id` (uuid | null) + * - `orden_celda` (number | null) + * + * IA config (optional): + * - `descripcionEnfoqueAcademico` (string) + * + * Notes: + * - This JSON flow does NOT accept `instruccionesAdicionalesIA`. + * - Missing optional fields are ignored (the existing DB values remain). + */ + +const JsonUpdateSchema = z + .object({ + id: z.string().uuid("id debe ser un UUID"), + + // patch fields (all optional) — nombres coinciden con columnas DB + nombre: z.string().min(1).optional(), + codigo: z.union([z.string().min(1), z.null()]).optional(), + tipo: z.union([z.string().min(1), z.null()]).optional(), + creditos: z.number().positive().optional(), + horas_academicas: z.number().int().nonnegative().nullable().optional(), + horas_independientes: z.number().int().nonnegative().nullable().optional(), + numero_ciclo: z.number().int().positive().nullable().optional(), + estructura_id: z.string().uuid().nullable().optional(), + plan_estudio_id: z.string().uuid().optional(), + linea_plan_id: z.string().uuid().nullable().optional(), + orden_celda: z.number().int().nonnegative().nullable().optional(), + + // IA config (no instruccionesAdicionalesIA) + descripcionEnfoqueAcademico: z.string().optional(), + }) + .strict(); + +type EdgeAIGenerateSubjectJsonUpdateInput = z.infer; + +function formatZodIssues(issues: z.ZodIssue[]): string { + return issues + .map((issue, i) => { + const path = issue.path.length ? issue.path.join(".") : "(root)"; + return `${i + 1}. ${path}: ${issue.message}`; + }) + .join("\n"); +} + Deno.serve(async (req: Request): Promise => { const url = new URL(req.url); const functionName = url.pathname.split("/").pop(); @@ -53,7 +113,9 @@ Deno.serve(async (req: Request): Promise => { } const contentType = (req.headers.get("content-type") || "").toLowerCase(); - if (!contentType.startsWith("multipart/form-data")) { + const isMultipart = contentType.startsWith("multipart/form-data"); + const isJson = contentType.includes("application/json"); + if (!isMultipart && !isJson) { console.error( `[${ new Date().toISOString() @@ -108,6 +170,299 @@ Deno.serve(async (req: Request): Promise => { SERVICE_ROLE_KEY, ); + // ----------------------------- + // JSON update flow + // ----------------------------- + if (isJson) { + let rawBody: unknown; + try { + rawBody = await req.json(); + } catch (e) { + throw new HttpError( + 400, + "Body JSON inválido.", + "INVALID_JSON", + { cause: e }, + ); + } + + const parsedBody = JsonUpdateSchema.safeParse(rawBody); + if (!parsedBody.success) { + throw new HttpError( + 422, + formatZodIssues(parsedBody.error.issues), + "VALIDATION_ERROR", + parsedBody.error, + ); + } + + const payload: EdgeAIGenerateSubjectJsonUpdateInput = parsedBody.data; + + const { data: existingAsignatura, error: existingAsignaturaError } = + await supabaseService + .from("asignaturas") + .select( + "id,plan_estudio_id,estructura_id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,numero_ciclo,linea_plan_id,orden_celda", + ) + .eq("id", payload.id) + .single(); + + if (existingAsignaturaError) { + const maybeCode = (existingAsignaturaError as { code?: string }).code; + if (maybeCode === "PGRST116") { + throw new HttpError( + 404, + "No se encontró la asignatura.", + "NOT_FOUND", + { table: "asignaturas", id: payload.id }, + ); + } + throw new HttpError( + 500, + "No se pudo obtener la asignatura.", + "SUPABASE_QUERY_FAILED", + existingAsignaturaError, + ); + } + + const resolved = { + plan_estudio_id: payload.plan_estudio_id ?? + existingAsignatura.plan_estudio_id, + estructura_id: payload.estructura_id ?? + existingAsignatura.estructura_id, + nombre: payload.nombre ?? existingAsignatura.nombre, + codigo: payload.codigo ?? existingAsignatura.codigo, + tipo: payload.tipo ?? existingAsignatura.tipo, + creditos: payload.creditos ?? existingAsignatura.creditos, + horas_academicas: payload.horas_academicas ?? + existingAsignatura.horas_academicas, + horas_independientes: payload.horas_independientes ?? + existingAsignatura.horas_independientes, + numero_ciclo: payload.numero_ciclo ?? existingAsignatura.numero_ciclo, + linea_plan_id: payload.linea_plan_id ?? + existingAsignatura.linea_plan_id, + orden_celda: payload.orden_celda ?? existingAsignatura.orden_celda, + }; + + if (!resolved.estructura_id) { + throw new HttpError( + 422, + "estructura_id es requerido (no está presente ni en el JSON ni en la asignatura existente).", + "VALIDATION_ERROR", + { + id: payload.id, + existing_estructura_id: existingAsignatura.estructura_id, + }, + ); + } + + const { data: estructura, error: estructuraError } = await supabaseService + .from("estructuras_asignatura") + .select("id,nombre,definicion,version") + .eq("id", resolved.estructura_id) + .single(); + if (estructuraError) { + const maybeCode = (estructuraError as { code?: string }).code; + if (maybeCode === "PGRST116") { + throw new HttpError( + 404, + "No se encontró la estructura de la asignatura.", + "NOT_FOUND", + { table: "estructuras_asignatura", id: resolved.estructura_id }, + ); + } + throw new HttpError( + 500, + "No se pudo obtener la estructura de la asignatura.", + "SUPABASE_QUERY_FAILED", + estructuraError, + ); + } + + const systemPrompt = + "Eres un asistente experto en diseño curricular. Responde únicamente con JSON válido que cumpla estrictamente el JSON Schema proporcionado."; + + const userPrompt = + `Genera un borrador completo completo de una ASIGNATURA con base en lo siguiente:\n` + + `- Nombre de la asignatura: ${resolved.nombre}\n` + + `- Código (clave de la asignatura): ${ + resolved.codigo ?? "(no especificado)" + }\n` + + `- Tipo: ${resolved.tipo ?? "(no especificado)"}\n` + + `- Número de créditos: ${resolved.creditos}\n` + + `- Horas académicas: ${ + resolved.horas_academicas ?? "(no especificado)" + }\n` + + `- Horas independientes: ${ + resolved.horas_independientes ?? "(no especificado)" + }\n` + + `- Descripción del enfoque académico (sobre el contenido de la respuesta generada): ${ + payload.descripcionEnfoqueAcademico ?? "(ninguna)" + }`; + + const schemaDef: Record = + typeof estructura?.definicion === "object" && + estructura?.definicion !== null + ? (estructura.definicion as Record) + : {}; + + const aiStructuredPayload: StructuredResponseOptions = { + model: "gpt-5-nano", + input: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + text: { + format: { + type: "json_schema", + name: "asignatura_contenido_tematico", + schema: schemaDef, + strict: true, + }, + }, + }; + + const svc = OpenAIService.fromEnv(); + if (!(svc instanceof OpenAIService)) { + throw new HttpError( + 500, + "Configuración del servidor incompleta.", + "OPENAI_MISCONFIGURED", + svc, + ); + } + + const aiResult = await svc.createStructuredResponse(aiStructuredPayload); + if (!aiResult.ok) { + const status = aiResult.code === "MissingEnv" ? 500 : 502; + throw new HttpError( + status, + "No se pudo generar la asignatura con IA.", + "OPENAI_REQUEST_FAILED", + aiResult, + ); + } + + let aiOutput = aiResult.output ?? null; + if (aiOutput == null && aiResult.outputText) { + try { + aiOutput = JSON.parse(aiResult.outputText); + } catch { + throw new HttpError( + 502, + "La respuesta de la IA no es JSON válido.", + "OPENAI_INVALID_JSON", + { outputText: aiResult.outputText }, + ); + } + } + if (!aiOutput) { + throw new HttpError( + 502, + "La respuesta de la IA no contiene salida estructurada.", + "OPENAI_MISSING_STRUCTURED_OUTPUT", + { outputText: aiResult.outputText ?? null }, + ); + } + + const aiOutputJson: Json = aiOutput as unknown as Json; + + const updatePatch: Database["public"]["Tables"]["asignaturas"]["Update"] = + { + datos: aiOutputJson, + tipo_origen: "IA", + estado: "borrador", + }; + + // Apply only provided JSON fields (do not overwrite if missing) + if (payload.plan_estudio_id !== undefined) { + updatePatch.plan_estudio_id = payload.plan_estudio_id; + } + if (payload.estructura_id !== undefined) { + updatePatch.estructura_id = payload.estructura_id; + } + if (payload.nombre !== undefined) { + updatePatch.nombre = payload.nombre; + } + if (payload.codigo !== undefined) { + updatePatch.codigo = payload.codigo; + } + if (payload.tipo !== undefined) { + updatePatch.tipo = payload.tipo as Database["public"]["Tables"][ + "asignaturas" + ]["Update"]["tipo"]; + } + if (payload.creditos !== undefined) { + updatePatch.creditos = payload.creditos; + } + if (payload.horas_academicas !== undefined) { + updatePatch.horas_academicas = payload.horas_academicas; + } + if (payload.horas_independientes !== undefined) { + updatePatch.horas_independientes = payload.horas_independientes; + } + if (payload.numero_ciclo !== undefined) { + updatePatch.numero_ciclo = payload.numero_ciclo; + } + if (payload.linea_plan_id !== undefined) { + updatePatch.linea_plan_id = payload.linea_plan_id; + } + if (payload.orden_celda !== undefined) { + updatePatch.orden_celda = payload.orden_celda; + } + + updatePatch.meta_origen = { + generado_por: "ai_generate_subject_update_json", + ai: { + responseId: aiResult.responseId ?? null, + conversationId: aiResult.conversationId ?? null, + model: aiResult.model, + usage: aiResult.usage ?? null, + }, + iaConfig: { + descripcionEnfoqueAcademico: payload.descripcionEnfoqueAcademico ?? + null, + }, + } as unknown as Json; + + const { data: asignatura, error: asignaturaError } = await supabaseService + .from("asignaturas") + .update(updatePatch) + .eq("id", payload.id) + .select( + "id,nombre,codigo,tipo,creditos,horas_academicas,horas_independientes,estructura_id,plan_estudio_id,contenido_tematico,meta_origen,datos,creado_en,actualizado_en,numero_ciclo,linea_plan_id,orden_celda", + ) + .single(); + + if (asignaturaError) { + const maybeCode = (asignaturaError as { code?: string }).code; + if (maybeCode === "PGRST116") { + throw new HttpError( + 404, + "No se encontró la asignatura.", + "NOT_FOUND", + { table: "asignaturas", id: payload.id }, + ); + } + throw new HttpError( + 500, + "No se pudo actualizar la asignatura.", + "SUPABASE_UPDATE_FAILED", + asignaturaError, + ); + } + + console.log( + `[${ + new Date().toISOString() + }][${functionName}]: JSON update processed successfully`, + ); + return sendSuccess(asignatura); + } + + // ----------------------------- + // Multipart create flow (existing) + // ----------------------------- const formData = await req.formData(); const validation = parseAndValidate(formData); if (!validation.success) { diff --git a/supabase/migrations/20260212214439_columna_estado_asignaturas.sql b/supabase/migrations/20260212214439_columna_estado_asignaturas.sql new file mode 100644 index 0000000..e17a355 --- /dev/null +++ b/supabase/migrations/20260212214439_columna_estado_asignaturas.sql @@ -0,0 +1,171 @@ +create type "public"."estado_asignatura" as enum ('borrador', 'revisada', 'aprobada', 'generando'); + +alter table "public"."asignaturas" add column "estado" public.estado_asignatura not null default 'generando'::public.estado_asignatura; + +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$ +; + + diff --git a/supabase/snippets/Untitled query 470.sql b/supabase/snippets/Untitled query 470.sql new file mode 100644 index 0000000..33c8b94 --- /dev/null +++ b/supabase/snippets/Untitled query 470.sql @@ -0,0 +1 @@ +alter publication supabase_realtime drop table asignaturas; \ No newline at end of file