588 lines
15 KiB
TypeScript
588 lines
15 KiB
TypeScript
import { supabaseBrowser } from '../supabase/client'
|
|
import { invokeEdge } from '../supabase/invokeEdge'
|
|
|
|
import { throwIfError, requireData } from './_helpers'
|
|
|
|
import type { DocumentoResult } from './plans.api'
|
|
import type {
|
|
Asignatura,
|
|
BibliografiaAsignatura,
|
|
CarreraRow,
|
|
CambioAsignatura,
|
|
EstructuraAsignatura,
|
|
FacultadRow,
|
|
PlanEstudioRow,
|
|
TipoAsignatura,
|
|
UUID,
|
|
} from '../types/domain'
|
|
import type {
|
|
AsignaturaSugerida,
|
|
DataAsignaturaSugerida,
|
|
} from '@/features/asignaturas/nueva/types'
|
|
import type { Database, Tables, TablesInsert } from '@/types/supabase'
|
|
|
|
const EDGE = {
|
|
generate_subject_suggestions: 'generate-subject-suggestions',
|
|
subjects_create_manual: 'subjects_create_manual',
|
|
ai_generate_subject: 'ai-generate-subject',
|
|
subjects_persist_from_ai: 'subjects_persist_from_ai',
|
|
subjects_clone_from_existing: 'subjects_clone_from_existing',
|
|
subjects_import_from_file: 'subjects_import_from_file',
|
|
|
|
// Bibliografía
|
|
buscar_bibliografia: 'buscar-bibliografia',
|
|
|
|
subjects_update_fields: 'subjects_update_fields',
|
|
subjects_update_bibliografia: 'subjects_update_bibliografia',
|
|
|
|
subjects_generate_document: 'subjects_generate_document',
|
|
subjects_get_document: 'subjects_get_document',
|
|
} as const
|
|
|
|
export type BuscarBibliografiaRequest = {
|
|
searchTerms: {
|
|
q: string
|
|
}
|
|
|
|
google: {
|
|
orderBy?: 'newest' | 'relevance'
|
|
langRestrict?: string
|
|
startIndex?: number
|
|
[k: string]: unknown
|
|
}
|
|
|
|
openLibrary: {
|
|
language?: string
|
|
page?: number
|
|
sort?: string
|
|
[k: string]: unknown
|
|
}
|
|
}
|
|
|
|
export type GoogleBooksVolume = {
|
|
kind?: 'books#volume'
|
|
id: string
|
|
etag?: string
|
|
selfLink?: string
|
|
volumeInfo?: {
|
|
title?: string
|
|
subtitle?: string
|
|
authors?: Array<string>
|
|
publisher?: string
|
|
publishedDate?: string
|
|
description?: string
|
|
industryIdentifiers?: Array<{ type?: string; identifier?: string }>
|
|
pageCount?: number
|
|
categories?: Array<string>
|
|
language?: string
|
|
previewLink?: string
|
|
infoLink?: string
|
|
canonicalVolumeLink?: string
|
|
imageLinks?: {
|
|
smallThumbnail?: string
|
|
thumbnail?: string
|
|
small?: string
|
|
medium?: string
|
|
large?: string
|
|
extraLarge?: string
|
|
}
|
|
}
|
|
searchInfo?: {
|
|
textSnippet?: string
|
|
}
|
|
[k: string]: unknown
|
|
}
|
|
|
|
export type OpenLibraryDoc = Record<string, unknown>
|
|
|
|
export type EndpointResult =
|
|
| { endpoint: 'google'; item: GoogleBooksVolume }
|
|
| { endpoint: 'open_library'; item: OpenLibraryDoc }
|
|
|
|
export async function buscar_bibliografia(
|
|
input: BuscarBibliografiaRequest,
|
|
): Promise<Array<EndpointResult>> {
|
|
const q = input.searchTerms.q
|
|
|
|
if (typeof q !== 'string' || q.trim().length < 1) {
|
|
throw new Error('q es requerido')
|
|
}
|
|
|
|
return await invokeEdge<Array<EndpointResult>>(
|
|
EDGE.buscar_bibliografia,
|
|
input,
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}
|
|
|
|
export type ContenidoTemaApi =
|
|
| string
|
|
| {
|
|
nombre: string
|
|
horasEstimadas?: number
|
|
descripcion?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/**
|
|
* Estructura persistida en `asignaturas.contenido_tematico`.
|
|
* La BDD guarda un arreglo de unidades, cada una con temas (strings u objetos).
|
|
*/
|
|
export type ContenidoApi = {
|
|
unidad: number
|
|
titulo: string
|
|
temas: Array<ContenidoTemaApi>
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export type FacultadInSubject = Pick<
|
|
FacultadRow,
|
|
'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono'
|
|
>
|
|
|
|
export type CarreraInSubject = Pick<
|
|
CarreraRow,
|
|
'id' | 'facultad_id' | 'nombre' | 'nombre_corto' | 'clave_sep' | 'activa'
|
|
> & {
|
|
facultades: FacultadInSubject | null
|
|
}
|
|
|
|
export type PlanEstudioInSubject = Pick<
|
|
PlanEstudioRow,
|
|
| 'id'
|
|
| 'carrera_id'
|
|
| 'estructura_id'
|
|
| 'nombre'
|
|
| 'nivel'
|
|
| 'tipo_ciclo'
|
|
| 'numero_ciclos'
|
|
| 'datos'
|
|
| 'estado_actual_id'
|
|
| 'activo'
|
|
| 'tipo_origen'
|
|
| 'meta_origen'
|
|
| 'creado_por'
|
|
| 'actualizado_por'
|
|
| 'creado_en'
|
|
| 'actualizado_en'
|
|
> & {
|
|
carreras: CarreraInSubject | null
|
|
}
|
|
|
|
export type EstructuraAsignaturaInSubject = Pick<
|
|
EstructuraAsignatura,
|
|
'id' | 'nombre' | 'definicion'
|
|
>
|
|
|
|
/**
|
|
* Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas).
|
|
* Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones.
|
|
*/
|
|
export type AsignaturaDetail = Omit<Asignatura, 'contenido_tematico'> & {
|
|
contenido_tematico: Array<ContenidoApi> | null
|
|
planes_estudio: PlanEstudioInSubject | null
|
|
estructuras_asignatura: EstructuraAsignaturaInSubject | null
|
|
}
|
|
|
|
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
|
const supabase = supabaseBrowser()
|
|
|
|
const { data, error } = await supabase
|
|
.from('asignaturas')
|
|
.select(
|
|
`
|
|
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
|
|
planes_estudio(
|
|
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
|
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
|
),
|
|
estructuras_asignatura(id,nombre,definicion)
|
|
`,
|
|
)
|
|
.eq('id', subjectId)
|
|
.single()
|
|
|
|
throwIfError(error)
|
|
return requireData(
|
|
data,
|
|
'Asignatura no encontrada.',
|
|
) as unknown as AsignaturaDetail
|
|
}
|
|
|
|
export async function subjects_history(
|
|
subjectId: UUID,
|
|
): Promise<Array<CambioAsignatura>> {
|
|
const supabase = supabaseBrowser()
|
|
const { data, error } = await supabase
|
|
.from('cambios_asignatura')
|
|
.select(
|
|
'id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id',
|
|
)
|
|
.eq('asignatura_id', subjectId)
|
|
.order('cambiado_en', { ascending: false })
|
|
|
|
throwIfError(error)
|
|
return data ?? []
|
|
}
|
|
|
|
export async function subjects_bibliografia_list(
|
|
subjectId: UUID,
|
|
): Promise<Array<BibliografiaAsignatura>> {
|
|
const supabase = supabaseBrowser()
|
|
const { data, error } = await supabase
|
|
.from('bibliografia_asignatura')
|
|
.select(
|
|
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
|
|
)
|
|
.eq('asignatura_id', subjectId)
|
|
.order('tipo', { ascending: true })
|
|
.order('creado_en', { ascending: true })
|
|
|
|
throwIfError(error)
|
|
return data ?? []
|
|
}
|
|
|
|
export async function subjects_create_manual(
|
|
payload: TablesInsert<'asignaturas'>,
|
|
): Promise<Asignatura> {
|
|
const supabase = supabaseBrowser()
|
|
const { data, error } = await supabase
|
|
.from('asignaturas')
|
|
.insert(payload)
|
|
.select()
|
|
.single()
|
|
|
|
throwIfError(error)
|
|
return requireData(data, 'No se pudo crear la asignatura.')
|
|
}
|
|
|
|
/**
|
|
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
|
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
|
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
|
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
|
*/
|
|
export type AISubjectUnifiedInput = {
|
|
datosUpdate: Partial<{
|
|
id: string
|
|
plan_estudio_id: string
|
|
estructura_id: string
|
|
nombre: string
|
|
codigo: string | null
|
|
tipo: string | null
|
|
creditos: number
|
|
horas_academicas: number | null
|
|
horas_independientes: number | null
|
|
numero_ciclo: number | null
|
|
linea_plan_id: string | null
|
|
orden_celda: number | null
|
|
}> & {
|
|
plan_estudio_id: string
|
|
}
|
|
iaConfig?: {
|
|
descripcionEnfoqueAcademico?: string
|
|
instruccionesAdicionalesIA?: string
|
|
archivosAdjuntos?: Array<string>
|
|
}
|
|
}
|
|
|
|
export async function subjects_get_maybe(
|
|
subjectId: UUID,
|
|
): Promise<Asignatura | null> {
|
|
const supabase = supabaseBrowser()
|
|
|
|
const { data, error } = await supabase
|
|
.from('asignaturas')
|
|
.select('id,plan_estudio_id,estado')
|
|
.eq('id', subjectId)
|
|
.maybeSingle()
|
|
|
|
throwIfError(error)
|
|
return (data ?? null) as unknown as Asignatura | null
|
|
}
|
|
|
|
export type GenerateSubjectSuggestionsInput = {
|
|
plan_estudio_id: UUID
|
|
enfoque?: string
|
|
cantidad_de_sugerencias: number
|
|
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>
|
|
}
|
|
|
|
export async function generate_subject_suggestions(
|
|
input: GenerateSubjectSuggestionsInput,
|
|
): Promise<Array<AsignaturaSugerida>> {
|
|
const raw = await invokeEdge<Array<DataAsignaturaSugerida>>(
|
|
EDGE.generate_subject_suggestions,
|
|
input,
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
|
|
return raw.map(
|
|
(s): AsignaturaSugerida => ({
|
|
id: crypto.randomUUID(),
|
|
selected: false,
|
|
source: 'IA',
|
|
nombre: s.nombre,
|
|
codigo: s.codigo,
|
|
tipo: s.tipo ?? null,
|
|
creditos: s.creditos ?? null,
|
|
horasAcademicas: s.horasAcademicas ?? null,
|
|
horasIndependientes: s.horasIndependientes ?? null,
|
|
descripcion: s.descripcion,
|
|
linea_plan_id: null,
|
|
numero_ciclo: null,
|
|
}),
|
|
)
|
|
}
|
|
|
|
export async function ai_generate_subject(
|
|
input: AISubjectUnifiedInput,
|
|
): Promise<any> {
|
|
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
export async function subjects_persist_from_ai(payload: {
|
|
planId: UUID
|
|
jsonAsignatura: any
|
|
}): Promise<Asignatura> {
|
|
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload)
|
|
}
|
|
|
|
export async function subjects_clone_from_existing(payload: {
|
|
asignaturaOrigenId: UUID
|
|
planDestinoId: UUID
|
|
overrides?: Partial<{
|
|
nombre: string
|
|
codigo: string
|
|
tipo: TipoAsignatura
|
|
creditos: number
|
|
horas_semana: number
|
|
}>
|
|
}): Promise<Asignatura> {
|
|
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload)
|
|
}
|
|
|
|
export async function subjects_import_from_file(payload: {
|
|
planId: UUID
|
|
archivoWordAsignaturaId: UUID
|
|
archivosAdicionalesIds?: Array<UUID>
|
|
}): Promise<Asignatura> {
|
|
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload)
|
|
}
|
|
|
|
/** Guardado de tarjetas/fields (Edge: merge server-side en asignaturas.datos y columnas) */
|
|
export type SubjectsUpdateFieldsPatch = Partial<{
|
|
codigo: string | null
|
|
nombre: string
|
|
tipo: TipoAsignatura
|
|
creditos: number
|
|
horas_semana: number | null
|
|
numero_ciclo: number | null
|
|
linea_plan_id: UUID | null
|
|
|
|
datos: Record<string, any>
|
|
}>
|
|
|
|
export async function subjects_update_fields(
|
|
subjectId: UUID,
|
|
patch: SubjectsUpdateFieldsPatch,
|
|
): Promise<Asignatura> {
|
|
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, {
|
|
subjectId,
|
|
patch,
|
|
})
|
|
}
|
|
|
|
export async function subjects_update_contenido(
|
|
subjectId: UUID,
|
|
unidades: Array<ContenidoApi>,
|
|
): Promise<Asignatura> {
|
|
const supabase = supabaseBrowser()
|
|
|
|
type AsignaturaUpdate = Database['public']['Tables']['asignaturas']['Update']
|
|
|
|
const { data, error } = await supabase
|
|
.from('asignaturas')
|
|
.update({
|
|
contenido_tematico:
|
|
unidades as unknown as AsignaturaUpdate['contenido_tematico'],
|
|
})
|
|
.eq('id', subjectId)
|
|
.select()
|
|
.single()
|
|
|
|
throwIfError(error)
|
|
return requireData(data, 'No se pudo actualizar la asignatura.')
|
|
}
|
|
|
|
export type BibliografiaUpsertInput = Array<{
|
|
id?: UUID
|
|
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
|
cita: string
|
|
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
|
|
biblioteca_item_id?: string | null
|
|
}>
|
|
|
|
export async function subjects_update_bibliografia(
|
|
subjectId: UUID,
|
|
entries: BibliografiaUpsertInput,
|
|
): Promise<{ ok: true }> {
|
|
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, {
|
|
subjectId,
|
|
entries,
|
|
})
|
|
}
|
|
|
|
/** Documento SEP asignatura */
|
|
/* export type DocumentoResult = {
|
|
archivoId: UUID;
|
|
signedUrl: string;
|
|
mimeType?: string;
|
|
nombre?: string;
|
|
}; */
|
|
|
|
export async function subjects_generate_document(
|
|
subjectId: UUID,
|
|
): Promise<DocumentoResult> {
|
|
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, {
|
|
subjectId,
|
|
})
|
|
}
|
|
|
|
export async function subjects_get_document(
|
|
subjectId: UUID,
|
|
): Promise<DocumentoResult | null> {
|
|
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, {
|
|
subjectId,
|
|
})
|
|
}
|
|
|
|
export async function subjects_get_structure_catalog(): Promise<
|
|
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
|
|
> {
|
|
const supabase = supabaseBrowser()
|
|
|
|
const { data, error } = await supabase
|
|
.from('estructuras_asignatura')
|
|
.select('*')
|
|
.order('nombre', { ascending: true })
|
|
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
export async function asignaturas_update(
|
|
asignaturaId: UUID,
|
|
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
|
|
): Promise<Asignatura> {
|
|
const supabase = supabaseBrowser()
|
|
|
|
const { data, error } = await supabase
|
|
.from('asignaturas')
|
|
.update(patch)
|
|
.eq('id', asignaturaId)
|
|
.select() // Trae la materia actualizada
|
|
.single()
|
|
|
|
throwIfError(error)
|
|
return requireData(data, 'No se pudo actualizar la asignatura.')
|
|
}
|
|
|
|
// Insertar una nueva línea
|
|
export async function lineas_insert(linea: {
|
|
nombre: string
|
|
plan_estudio_id: string
|
|
orden: number
|
|
area?: string
|
|
}) {
|
|
const supabase = supabaseBrowser()
|
|
const { data, error } = await supabase
|
|
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
|
|
.insert([linea])
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
}
|
|
|
|
// Actualizar una línea existente
|
|
export async function lineas_update(
|
|
lineaId: string,
|
|
patch: { nombre?: string; orden?: number; area?: string },
|
|
) {
|
|
const supabase = supabaseBrowser()
|
|
const { data, error } = await supabase
|
|
.from('lineas_plan')
|
|
.update(patch)
|
|
.eq('id', lineaId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
}
|
|
|
|
export async function lineas_delete(lineaId: string) {
|
|
const supabase = supabaseBrowser()
|
|
|
|
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
|
|
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
|
|
const { error } = await supabase
|
|
.from('lineas_plan')
|
|
.delete()
|
|
.eq('id', lineaId)
|
|
|
|
if (error) throw error
|
|
return lineaId
|
|
}
|
|
|
|
export async function bibliografia_insert(
|
|
entry: TablesInsert<'bibliografia_asignatura'>,
|
|
): Promise<Tables<'bibliografia_asignatura'>> {
|
|
const supabase = supabaseBrowser()
|
|
const { data, error } = await supabase
|
|
.from('bibliografia_asignatura')
|
|
.insert([entry])
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data as Tables<'bibliografia_asignatura'>
|
|
}
|
|
|
|
export async function bibliografia_update(
|
|
id: string,
|
|
updates: {
|
|
cita?: string
|
|
tipo?: 'BASICA' | 'COMPLEMENTARIA'
|
|
},
|
|
) {
|
|
const supabase = supabaseBrowser()
|
|
const { data, error } = await supabase
|
|
.from('bibliografia_asignatura')
|
|
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) throw error
|
|
return data
|
|
}
|
|
|
|
export async function bibliografia_delete(id: string) {
|
|
const supabase = supabaseBrowser()
|
|
const { error } = await supabase
|
|
.from('bibliografia_asignatura')
|
|
.delete()
|
|
.eq('id', id)
|
|
|
|
if (error) throw error
|
|
return id
|
|
}
|