diff --git a/bun.lock b/bun.lock index cb925c7..1840b3e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@stepperize/react": "^5.1.9", + "@supabase/supabase-js": "^2.90.1", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", "@tanstack/react-query": "^5.66.5", @@ -377,6 +378,18 @@ "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.6.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw=="], + "@supabase/auth-js": ["@supabase/auth-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.90.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.90.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.90.1", "", { "dependencies": { "@supabase/auth-js": "2.90.1", "@supabase/functions-js": "2.90.1", "@supabase/postgrest-js": "2.90.1", "@supabase/realtime-js": "2.90.1", "@supabase/storage-js": "2.90.1" } }, "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -481,10 +494,14 @@ "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], + "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="], @@ -857,6 +874,8 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], diff --git a/package.json b/package.json index ebf5e6a..f26f385 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@stepperize/react": "^5.1.9", + "@supabase/supabase-js": "^2.90.1", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", "@tanstack/react-query": "^5.66.5", diff --git a/src/data/api/_helpers.ts b/src/data/api/_helpers.ts new file mode 100644 index 0000000..3d2176f --- /dev/null +++ b/src/data/api/_helpers.ts @@ -0,0 +1,45 @@ +import type { PostgrestError, AuthError, SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "../types/database"; + +export class ApiError extends Error { + constructor( + message: string, + public readonly code?: string, + public readonly details?: unknown, + public readonly hint?: string + ) { + super(message); + this.name = "ApiError"; + } +} + +export function throwIfError(error: PostgrestError | AuthError | null): void { + if (!error) return; + + const anyErr = error as any; + throw new ApiError( + anyErr.message ?? "Error inesperado", + anyErr.code, + anyErr.details, + anyErr.hint + ); +} + +export function requireData(data: T | null | undefined, message = "Respuesta vacía"): T { + if (data === null || data === undefined) throw new ApiError(message); + return data; +} + +export async function getUserIdOrThrow(supabase: SupabaseClient): Promise { + const { data, error } = await supabase.auth.getUser(); + throwIfError(error); + if (!data?.user?.id) throw new ApiError("No hay sesión activa (auth)."); + return data.user.id; +} + +export function buildRange(limit?: number, offset?: number): { from?: number; to?: number } { + if (!limit) return {}; + const from = Math.max(0, offset ?? 0); + const to = from + Math.max(1, limit) - 1; + return { from, to }; +} diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts new file mode 100644 index 0000000..aea39d5 --- /dev/null +++ b/src/data/api/ai.api.ts @@ -0,0 +1,81 @@ +import { invokeEdge } from "../supabase/invokeEdge"; +import type { InteraccionIA, UUID } from "../types/domain"; + +const EDGE = { + ai_plan_improve: "ai_plan_improve", + ai_plan_chat: "ai_plan_chat", + ai_subject_improve: "ai_subject_improve", + ai_subject_chat: "ai_subject_chat", + + library_search: "library_search", +} as const; + +export async function ai_plan_improve(payload: { + planId: UUID; + sectionKey: string; // ej: "perfil_de_egreso" o tu key interna + prompt: string; + context?: Record; + fuentes?: { + archivosIds?: UUID[]; + vectorStoresIds?: UUID[]; + usarMCP?: boolean; + conversacionId?: string; + }; +}): Promise<{ interaccion: InteraccionIA; propuesta: any }> { + return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_plan_improve, payload); +} + +export async function ai_plan_chat(payload: { + planId: UUID; + messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; + fuentes?: { + archivosIds?: UUID[]; + vectorStoresIds?: UUID[]; + usarMCP?: boolean; + conversacionId?: string; + }; +}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> { + return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_plan_chat, payload); +} + +export async function ai_subject_improve(payload: { + subjectId: UUID; + sectionKey: string; + prompt: string; + context?: Record; + fuentes?: { + archivosIds?: UUID[]; + vectorStoresIds?: UUID[]; + usarMCP?: boolean; + conversacionId?: string; + }; +}): Promise<{ interaccion: InteraccionIA; propuesta: any }> { + return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_subject_improve, payload); +} + +export async function ai_subject_chat(payload: { + subjectId: UUID; + messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; + fuentes?: { + archivosIds?: UUID[]; + vectorStoresIds?: UUID[]; + usarMCP?: boolean; + conversacionId?: string; + }; +}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> { + return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_subject_chat, payload); +} + +/** Biblioteca (Edge; adapta a tu API real) */ +export type LibraryItem = { + id: string; + titulo: string; + autor?: string; + isbn?: string; + citaSugerida?: string; + disponibilidad?: string; +}; + +export async function library_search(payload: { query: string; limit?: number }): Promise { + return invokeEdge(EDGE.library_search, payload); +} diff --git a/src/data/api/files.api.ts b/src/data/api/files.api.ts new file mode 100644 index 0000000..7341abf --- /dev/null +++ b/src/data/api/files.api.ts @@ -0,0 +1,63 @@ +import { supabaseBrowser } from "../supabase/client"; +import { throwIfError, requireData, getUserIdOrThrow } from "./_helpers"; +import type { Archivo, UUID } from "../types/domain"; + +const DEFAULT_BUCKET = "archivos"; + +export type UploadFileInput = { + file: File; + bucket?: string; + pathPrefix?: string; // ej: "planes/" o "materias/" + temporal?: boolean; + notas?: string | null; +}; + +export async function files_upload(input: UploadFileInput): Promise { + const supabase = supabaseBrowser(); + const userId = await getUserIdOrThrow(supabase); + + const bucket = input.bucket ?? DEFAULT_BUCKET; + const safeName = input.file.name.replace(/[^\w.\-() ]+/g, "_"); + const pathPrefix = (input.pathPrefix ?? `usuarios/${userId}`).replace(/\/+$/g, ""); + + const storagePath = `${pathPrefix}/${crypto.randomUUID()}-${safeName}`; + + const { data: upData, error: upErr } = await supabase.storage + .from(bucket) + .upload(storagePath, input.file, { upsert: false }); + + throwIfError(upErr); + requireData(upData, "No se pudo subir archivo."); + + const { data: row, error: insErr } = await supabase + .from("archivos") + .insert({ + ruta_storage: `${bucket}/${storagePath}`, + nombre: input.file.name, + mime_type: input.file.type || null, + bytes: input.file.size, + subido_por: userId as UUID, + temporal: Boolean(input.temporal), + notas: input.notas ?? null, + }) + .select("id,ruta_storage,nombre,mime_type,bytes,subido_por,subido_en,temporal,openai_file_id,notas") + .single(); + + throwIfError(insErr); + return requireData(row, "No se pudo registrar metadata del archivo."); +} + +export async function files_signed_url(params: { + ruta_storage: string; // "bucket/path/to/file" + expiresIn?: number; // segundos +}): Promise { + const supabase = supabaseBrowser(); + const expires = params.expiresIn ?? 60 * 10; + + const [bucket, ...rest] = params.ruta_storage.split("/"); + const path = rest.join("/"); + + const { data, error } = await supabase.storage.from(bucket).createSignedUrl(path, expires); + throwIfError(error); + return requireData(data?.signedUrl, "No se pudo generar URL firmada."); +} diff --git a/src/data/api/meta.api.ts b/src/data/api/meta.api.ts new file mode 100644 index 0000000..98ab585 --- /dev/null +++ b/src/data/api/meta.api.ts @@ -0,0 +1,66 @@ +import { supabaseBrowser } from "../supabase/client"; +import { throwIfError } from "./_helpers"; +import type { Carrera, EstadoPlan, EstructuraAsignatura, EstructuraPlan, Facultad } from "../types/domain"; + +export async function facultades_list(): Promise { + const supabase = supabaseBrowser(); + const { data, error } = await supabase + .from("facultades") + .select("id,nombre,nombre_corto,color,icono,creado_en,actualizado_en") + .order("nombre", { ascending: true }); + + throwIfError(error); + return data ?? []; +} + +export async function carreras_list(params?: { facultadId?: string | null }): Promise { + const supabase = supabaseBrowser(); + + let q = supabase + .from("carreras") + .select( + "id,facultad_id,nombre,nombre_corto,clave_sep,activa,creado_en,actualizado_en, facultades(id,nombre,nombre_corto,color,icono)" + ) + .order("nombre", { ascending: true }); + + if (params?.facultadId) q = q.eq("facultad_id", params.facultadId); + + const { data, error } = await q; + throwIfError(error); + return data ?? []; +} + +export async function estructuras_plan_list(params?: { nivel?: string | null }): Promise { + const supabase = supabaseBrowser(); + + // Nota: en tu DDL no hay "nivel" en estructuras_plan; si luego lo agregas, filtra aquí. + const { data, error } = await supabase + .from("estructuras_plan") + .select("id,nombre,tipo,version,definicion") + .order("nombre", { ascending: true }); + + throwIfError(error); + return data ?? []; +} + +export async function estructuras_asignatura_list(): Promise { + const supabase = supabaseBrowser(); + const { data, error } = await supabase + .from("estructuras_asignatura") + .select("id,nombre,version,definicion") + .order("nombre", { ascending: true }); + + throwIfError(error); + return data ?? []; +} + +export async function estados_plan_list(): Promise { + const supabase = supabaseBrowser(); + const { data, error } = await supabase + .from("estados_plan") + .select("id,clave,etiqueta,orden,es_final") + .order("orden", { ascending: true }); + + throwIfError(error); + return data ?? []; +} diff --git a/src/data/api/notifications.api.ts b/src/data/api/notifications.api.ts new file mode 100644 index 0000000..0e1ba86 --- /dev/null +++ b/src/data/api/notifications.api.ts @@ -0,0 +1,31 @@ +import { supabaseBrowser } from "../supabase/client"; +import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers"; +import type { Notificacion, UUID } from "../types/domain"; + +export async function notificaciones_mias_list(): Promise { + const supabase = supabaseBrowser(); + const userId = await getUserIdOrThrow(supabase); + + const { data, error } = await supabase + .from("notificaciones") + .select("id,usuario_id,tipo,payload,leida,creado_en,leida_en") + .eq("usuario_id", userId as UUID) + .order("creado_en", { ascending: false }); + + throwIfError(error); + return data ?? []; +} + +export async function notificaciones_marcar_leida(notificacionId: UUID): Promise { + const supabase = supabaseBrowser(); + + const { data, error } = await supabase + .from("notificaciones") + .update({ leida: true, leida_en: new Date().toISOString() }) + .eq("id", notificacionId) + .select("id,usuario_id,tipo,payload,leida,creado_en,leida_en") + .single(); + + throwIfError(error); + return requireData(data, "No se pudo marcar notificación."); +} diff --git a/src/data/api/plans.api.ts b/src/data/api/plans.api.ts new file mode 100644 index 0000000..815a6a6 --- /dev/null +++ b/src/data/api/plans.api.ts @@ -0,0 +1,260 @@ +import { supabaseBrowser } from "../supabase/client"; +import { invokeEdge } from "../supabase/invokeEdge"; +import { buildRange, throwIfError, requireData } from "./_helpers"; +import type { + Asignatura, + CambioPlan, + LineaPlan, + NivelPlanEstudio, + Paged, + PlanDatosSep, + PlanEstudio, + TipoCiclo, + UUID, +} from "../types/domain"; + +const EDGE = { + plans_create_manual: "plans_create_manual", + ai_generate_plan: "ai_generate_plan", + plans_persist_from_ai: "plans_persist_from_ai", + plans_clone_from_existing: "plans_clone_from_existing", + plans_import_from_files: "plans_import_from_files", + + plans_update_fields: "plans_update_fields", + plans_update_map: "plans_update_map", + plans_transition_state: "plans_transition_state", + + plans_generate_document: "plans_generate_document", + plans_get_document: "plans_get_document", +} as const; + +export type PlanListFilters = { + search?: string; + carreraId?: UUID; + facultadId?: UUID; // filtra por carreras.facultad_id + estadoId?: UUID; + activo?: boolean; + + limit?: number; + offset?: number; +}; + +export async function plans_list(filters: PlanListFilters = {}): Promise> { + const supabase = supabaseBrowser(); + + let q = supabase + .from("planes_estudio") + .select( + ` + 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_plan(id,nombre,tipo,version,definicion), + estados_plan(id,clave,etiqueta,orden,es_final) + `, + { count: "exact" } + ) + .order("actualizado_en", { ascending: false }); + + if (filters.search?.trim()) q = q.ilike("nombre", `%${filters.search.trim()}%`); + if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId); + if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId); + if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo); + + // filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos) + if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId); + + const { from, to } = buildRange(filters.limit, filters.offset); + if (typeof from === "number" && typeof to === "number") q = q.range(from, to); + + const { data, error, count } = await q; + throwIfError(error); + + return { data: data ?? [], count: count ?? null }; +} + +export async function plans_get(planId: UUID): Promise { + const supabase = supabaseBrowser(); + + const { data, error } = await supabase + .from("planes_estudio") + .select( + ` + 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_plan(id,nombre,tipo,version,definicion), + estados_plan(id,clave,etiqueta,orden,es_final) + ` + ) + .eq("id", planId) + .single(); + + throwIfError(error); + return requireData(data, "Plan no encontrado."); +} + +export async function plan_lineas_list(planId: UUID): Promise { + const supabase = supabaseBrowser(); + const { data, error } = await supabase + .from("lineas_plan") + .select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en") + .eq("plan_estudio_id", planId) + .order("orden", { ascending: true }); + + throwIfError(error); + return data ?? []; +} + +export async function plan_asignaturas_list(planId: UUID): Promise { + const supabase = supabaseBrowser(); + const { data, error } = await supabase + .from("asignaturas") + .select( + "id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en" + ) + .eq("plan_estudio_id", planId) + .order("numero_ciclo", { ascending: true, nullsFirst: false }) + .order("orden_celda", { ascending: true, nullsFirst: false }) + .order("nombre", { ascending: true }); + + throwIfError(error); + return data ?? []; +} + +export async function plans_history(planId: UUID): Promise { + const supabase = supabaseBrowser(); + const { data, error } = await supabase + .from("cambios_plan") + .select("id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id") + .eq("plan_estudio_id", planId) + .order("cambiado_en", { ascending: false }); + + throwIfError(error); + return data ?? []; +} + +/** Wizard: crear plan manual (Edge Function) */ +export type PlansCreateManualInput = { + carreraId: UUID; + estructuraId: UUID; + nombre: string; + nivel: NivelPlanEstudio; + tipoCiclo: TipoCiclo; + numCiclos: number; + datos?: Partial & Record; +}; + +export async function plans_create_manual(input: PlansCreateManualInput): Promise { + return invokeEdge(EDGE.plans_create_manual, input); +} + +/** Wizard: IA genera preview JSON (Edge Function) */ +export type AIGeneratePlanInput = { + datosBasicos: { + nombrePlan: string; + carreraId: UUID; + facultadId?: UUID; + nivel: string; + tipoCiclo: TipoCiclo; + numCiclos: number; + }; + iaConfig: { + descripcionEnfoque: string; + poblacionObjetivo?: string; + notasAdicionales?: string; + archivosReferencia?: UUID[]; + repositoriosIds?: UUID[]; + usarMCP?: boolean; + }; +}; + +export async function ai_generate_plan(input: AIGeneratePlanInput): Promise { + return invokeEdge(EDGE.ai_generate_plan, input); +} + +export async function plans_persist_from_ai(payload: { jsonPlan: any }): Promise { + return invokeEdge(EDGE.plans_persist_from_ai, payload); +} + +export async function plans_clone_from_existing(payload: { + planOrigenId: UUID; + overrides: Partial> & { + carrera_id?: UUID; + estructura_id?: UUID; + datos?: Partial & Record; + }; +}): Promise { + return invokeEdge(EDGE.plans_clone_from_existing, payload); +} + +export async function plans_import_from_files(payload: { + datosBasicos: { + nombrePlan: string; + carreraId: UUID; + estructuraId: UUID; + nivel: string; + tipoCiclo: TipoCiclo; + numCiclos: number; + }; + archivoWordPlanId: UUID; + archivoMapaExcelId?: UUID | null; + archivoMateriasExcelId?: UUID | null; +}): Promise { + return invokeEdge(EDGE.plans_import_from_files, payload); +} + +/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */ +export type PlansUpdateFieldsPatch = { + nombre?: string; + nivel?: NivelPlanEstudio; + tipo_ciclo?: TipoCiclo; + numero_ciclos?: number; + datos?: Partial & Record; +}; + +export async function plans_update_fields(planId: UUID, patch: PlansUpdateFieldsPatch): Promise { + return invokeEdge(EDGE.plans_update_fields, { planId, patch }); +} + +/** Operaciones del mapa curricular (mover/reordenar) */ +export type PlanMapOperation = + | { + op: "MOVE_ASIGNATURA"; + asignaturaId: UUID; + numero_ciclo: number | null; + linea_plan_id: UUID | null; + orden_celda?: number | null; + } + | { + op: "REORDER_CELDA"; + linea_plan_id: UUID; + numero_ciclo: number; + asignaturaIdsOrdenados: UUID[]; + }; + +export async function plans_update_map(planId: UUID, ops: PlanMapOperation[]): Promise<{ ok: true }> { + return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops }); +} + +export async function plans_transition_state(payload: { + planId: UUID; + haciaEstadoId: UUID; + comentario?: string; +}): Promise<{ ok: true }> { + return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload); +} + +/** Documento (Edge Function: genera y devuelve URL firmada o metadata) */ +export type DocumentoResult = { + archivoId: UUID; + signedUrl: string; + mimeType?: string; + nombre?: string; +}; + +export async function plans_generate_document(planId: UUID): Promise { + return invokeEdge(EDGE.plans_generate_document, { planId }); +} + +export async function plans_get_document(planId: UUID): Promise { + return invokeEdge(EDGE.plans_get_document, { planId }); +} diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts new file mode 100644 index 0000000..61d0fdc --- /dev/null +++ b/src/data/api/subjects.api.ts @@ -0,0 +1,192 @@ +import { supabaseBrowser } from "../supabase/client"; +import { invokeEdge } from "../supabase/invokeEdge"; +import { throwIfError, requireData } from "./_helpers"; +import type { + Asignatura, + BibliografiaAsignatura, + CambioAsignatura, + TipoAsignatura, + UUID, +} from "../types/domain"; +import type { DocumentoResult } from "./plans.api"; + +const EDGE = { + 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", + + subjects_update_fields: "subjects_update_fields", + subjects_update_contenido: "subjects_update_contenido", + subjects_update_bibliografia: "subjects_update_bibliografia", + + subjects_generate_document: "subjects_generate_document", + subjects_get_document: "subjects_get_document", +} as const; + +export async function subjects_get(subjectId: UUID): Promise { + const supabase = supabaseBrowser(); + + const { data, error } = await supabase + .from("asignaturas") + .select( + ` + id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, + 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,version,definicion) + ` + ) + .eq("id", subjectId) + .single(); + + throwIfError(error); + return requireData(data, "Materia no encontrada."); +} + +export async function subjects_history(subjectId: UUID): Promise { + 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 { + const supabase = supabaseBrowser(); + const { data, error } = await supabase + .from("bibliografia_asignatura") + .select("id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en") + .eq("asignatura_id", subjectId) + .order("tipo", { ascending: true }) + .order("creado_en", { ascending: true }); + + throwIfError(error); + return data ?? []; +} + +/** Wizard: crear materia manual (Edge Function) */ +export type SubjectsCreateManualInput = { + planId: UUID; + datosBasicos: { + nombre: string; + clave?: string; + tipo: TipoAsignatura; + creditos: number; + horasSemana?: number; + estructuraId: UUID; + }; +}; + +export async function subjects_create_manual(payload: SubjectsCreateManualInput): Promise { + return invokeEdge(EDGE.subjects_create_manual, payload); +} + +export async function ai_generate_subject(payload: { + planId: UUID; + datosBasicos: { + nombre: string; + clave?: string; + tipo: TipoAsignatura; + creditos: number; + horasSemana?: number; + estructuraId: UUID; + }; + iaConfig: { + descripcionEnfoque: string; + notasAdicionales?: string; + archivosExistentesIds?: UUID[]; + repositoriosIds?: UUID[]; + archivosAdhocIds?: UUID[]; + usarMCP?: boolean; + }; +}): Promise { + return invokeEdge(EDGE.ai_generate_subject, payload); +} + +export async function subjects_persist_from_ai(payload: { planId: UUID; jsonMateria: any }): Promise { + return invokeEdge(EDGE.subjects_persist_from_ai, payload); +} + +export async function subjects_clone_from_existing(payload: { + materiaOrigenId: UUID; + planDestinoId: UUID; + overrides?: Partial<{ + nombre: string; + codigo: string; + tipo: TipoAsignatura; + creditos: number; + horas_semana: number; + }>; +}): Promise { + return invokeEdge(EDGE.subjects_clone_from_existing, payload); +} + +export async function subjects_import_from_file(payload: { + planId: UUID; + archivoWordMateriaId: UUID; + archivosAdicionalesIds?: UUID[]; +}): Promise { + return invokeEdge(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; +}>; + +export async function subjects_update_fields(subjectId: UUID, patch: SubjectsUpdateFieldsPatch): Promise { + return invokeEdge(EDGE.subjects_update_fields, { subjectId, patch }); +} + +export async function subjects_update_contenido(subjectId: UUID, unidades: any[]): Promise { + return invokeEdge(EDGE.subjects_update_contenido, { subjectId, unidades }); +} + +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 materia */ +/* export type DocumentoResult = { + archivoId: UUID; + signedUrl: string; + mimeType?: string; + nombre?: string; +}; */ + +export async function subjects_generate_document(subjectId: UUID): Promise { + return invokeEdge(EDGE.subjects_generate_document, { subjectId }); +} + +export async function subjects_get_document(subjectId: UUID): Promise { + return invokeEdge(EDGE.subjects_get_document, { subjectId }); +} diff --git a/src/data/api/tasks.api.ts b/src/data/api/tasks.api.ts new file mode 100644 index 0000000..4c8d394 --- /dev/null +++ b/src/data/api/tasks.api.ts @@ -0,0 +1,31 @@ +import { supabaseBrowser } from "../supabase/client"; +import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers"; +import type { TareaRevision, UUID } from "../types/domain"; + +export async function tareas_mias_list(): Promise { + const supabase = supabaseBrowser(); + const userId = await getUserIdOrThrow(supabase); + + const { data, error } = await supabase + .from("tareas_revision") + .select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en") + .eq("asignado_a", userId as UUID) + .order("creado_en", { ascending: false }); + + throwIfError(error); + return data ?? []; +} + +export async function tareas_marcar_completada(tareaId: UUID): Promise { + const supabase = supabaseBrowser(); + + const { data, error } = await supabase + .from("tareas_revision") + .update({ estatus: "COMPLETADA", completado_en: new Date().toISOString() }) + .eq("id", tareaId) + .select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en") + .single(); + + throwIfError(error); + return requireData(data, "No se pudo marcar tarea."); +} diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts new file mode 100644 index 0000000..4911ddd --- /dev/null +++ b/src/data/hooks/useAI.ts @@ -0,0 +1,28 @@ +import { useMutation } from "@tanstack/react-query"; +import { + ai_plan_chat, + ai_plan_improve, + ai_subject_chat, + ai_subject_improve, + library_search, +} from "../api/ai.api"; + +export function useAIPlanImprove() { + return useMutation({ mutationFn: ai_plan_improve }); +} + +export function useAIPlanChat() { + return useMutation({ mutationFn: ai_plan_chat }); +} + +export function useAISubjectImprove() { + return useMutation({ mutationFn: ai_subject_improve }); +} + +export function useAISubjectChat() { + return useMutation({ mutationFn: ai_subject_chat }); +} + +export function useLibrarySearch() { + return useMutation({ mutationFn: library_search }); +} diff --git a/src/data/hooks/useAuth.ts b/src/data/hooks/useAuth.ts new file mode 100644 index 0000000..90e68cd --- /dev/null +++ b/src/data/hooks/useAuth.ts @@ -0,0 +1,59 @@ +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabaseBrowser } from "../supabase/client"; +import { qk } from "../query/keys"; +import { throwIfError } from "../api/_helpers"; + +export function useSession() { + const supabase = supabaseBrowser(); + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: qk.session(), + queryFn: async () => { + const { data, error } = await supabase.auth.getSession(); + throwIfError(error); + return data.session ?? null; + }, + staleTime: Infinity, + }); + + useEffect(() => { + const { data } = supabase.auth.onAuthStateChange(() => { + qc.invalidateQueries({ queryKey: qk.session() }); + qc.invalidateQueries({ queryKey: qk.meProfile() }); + qc.invalidateQueries({ queryKey: qk.auth }); + }); + + return () => data.subscription.unsubscribe(); + }, [supabase, qc]); + + return query; +} + +export function useMeProfile() { + const supabase = supabaseBrowser(); + + return useQuery({ + queryKey: qk.meProfile(), + queryFn: async () => { + const { data: u, error: uErr } = await supabase.auth.getUser(); + throwIfError(uErr); + const userId = u.user?.id; + if (!userId) return null; + + const { data, error } = await supabase + .from("usuarios_app") + .select("id,nombre_completo,email,externo,creado_en,actualizado_en") + .eq("id", userId) + .single(); + + // si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo) + if (error && (error as any).code === "PGRST116") return null; + + throwIfError(error); + return data ?? null; + }, + staleTime: 60_000, + }); +} diff --git a/src/data/hooks/useMeta.ts b/src/data/hooks/useMeta.ts new file mode 100644 index 0000000..339911a --- /dev/null +++ b/src/data/hooks/useMeta.ts @@ -0,0 +1,49 @@ +import { useQuery } from "@tanstack/react-query"; +import { qk } from "../query/keys"; +import { + carreras_list, + estados_plan_list, + estructuras_asignatura_list, + estructuras_plan_list, + facultades_list, +} from "../api/meta.api"; + +export function useFacultades() { + return useQuery({ + queryKey: qk.facultades(), + queryFn: facultades_list, + staleTime: 5 * 60_000, + }); +} + +export function useCarreras(params?: { facultadId?: string | null }) { + return useQuery({ + queryKey: qk.carreras(params?.facultadId ?? null), + queryFn: () => carreras_list(params), + staleTime: 5 * 60_000, + }); +} + +export function useEstructurasPlan(params?: { nivel?: string | null }) { + return useQuery({ + queryKey: qk.estructurasPlan(params?.nivel ?? null), + queryFn: () => estructuras_plan_list(params), + staleTime: 10 * 60_000, + }); +} + +export function useEstructurasAsignatura() { + return useQuery({ + queryKey: qk.estructurasAsignatura(), + queryFn: estructuras_asignatura_list, + staleTime: 10 * 60_000, + }); +} + +export function useEstadosPlan() { + return useQuery({ + queryKey: qk.estadosPlan(), + queryFn: estados_plan_list, + staleTime: 10 * 60_000, + }); +} diff --git a/src/data/hooks/useNotifications.ts b/src/data/hooks/useNotifications.ts new file mode 100644 index 0000000..f46ff69 --- /dev/null +++ b/src/data/hooks/useNotifications.ts @@ -0,0 +1,49 @@ +import { useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../query/keys"; +import { notificaciones_marcar_leida, notificaciones_mias_list } from "../api/notifications.api"; +import { supabaseBrowser } from "../supabase/client"; + +export function useMisNotificaciones() { + return useQuery({ + queryKey: qk.notificaciones(), + queryFn: notificaciones_mias_list, + staleTime: 10_000, + }); +} + +/** 🔥 Opcional: realtime (si tienes Realtime habilitado) */ +export function useRealtimeNotificaciones(enable = true) { + const supabase = supabaseBrowser(); + const qc = useQueryClient(); + + useEffect(() => { + if (!enable) return; + + const channel = supabase + .channel("rt-notificaciones") + .on( + "postgres_changes", + { event: "*", schema: "public", table: "notificaciones" }, + () => { + qc.invalidateQueries({ queryKey: qk.notificaciones() }); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [enable, supabase, qc]); +} + +export function useMarcarNotificacionLeida() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: notificaciones_marcar_leida, + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.notificaciones() }); + }, + }); +} diff --git a/src/data/hooks/usePlans.ts b/src/data/hooks/usePlans.ts new file mode 100644 index 0000000..4886a93 --- /dev/null +++ b/src/data/hooks/usePlans.ts @@ -0,0 +1,210 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../query/keys"; +import type { PlanEstudio, UUID } from "../types/domain"; +import type { PlanListFilters, PlanMapOperation, PlansCreateManualInput, PlansUpdateFieldsPatch } from "../api/plans.api"; +import { + ai_generate_plan, + plan_asignaturas_list, + plan_lineas_list, + plans_clone_from_existing, + plans_create_manual, + plans_generate_document, + plans_get, + plans_get_document, + plans_history, + plans_import_from_files, + plans_list, + plans_persist_from_ai, + plans_transition_state, + plans_update_fields, + plans_update_map, +} from "../api/plans.api"; + +export function usePlanes(filters: PlanListFilters) { + // 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable. + return useQuery({ + queryKey: qk.planesList(filters), + queryFn: () => plans_list(filters), + placeholderData: keepPreviousData, + }); +} + +export function usePlan(planId: UUID | null | undefined) { + return useQuery({ + queryKey: planId ? qk.plan(planId) : ["planes", "detail", null], + queryFn: () => plans_get(planId as UUID), + enabled: Boolean(planId), + }); +} + +export function usePlanLineas(planId: UUID | null | undefined) { + return useQuery({ + queryKey: planId ? qk.planLineas(planId) : ["planes", "lineas", null], + queryFn: () => plan_lineas_list(planId as UUID), + enabled: Boolean(planId), + }); +} + +export function usePlanAsignaturas(planId: UUID | null | undefined) { + return useQuery({ + queryKey: planId ? qk.planAsignaturas(planId) : ["planes", "asignaturas", null], + queryFn: () => plan_asignaturas_list(planId as UUID), + enabled: Boolean(planId), + }); +} + +export function usePlanHistorial(planId: UUID | null | undefined) { + return useQuery({ + queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null], + queryFn: () => plans_history(planId as UUID), + enabled: Boolean(planId), + }); +} + +export function usePlanDocumento(planId: UUID | null | undefined) { + return useQuery({ + queryKey: planId ? qk.planDocumento(planId) : ["planes", "documento", null], + queryFn: () => plans_get_document(planId as UUID), + enabled: Boolean(planId), + staleTime: 30_000, + }); +} + +/* ------------------ Mutations ------------------ */ + +export function useCreatePlanManual() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input), + onSuccess: (plan) => { + qc.invalidateQueries({ queryKey: ["planes", "list"] }); + qc.setQueryData(qk.plan(plan.id), plan); + }, + }); +} + +export function useGeneratePlanAI() { + return useMutation({ + mutationFn: ai_generate_plan, + }); +} + +export function usePersistPlanFromAI() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload), + onSuccess: (plan) => { + qc.invalidateQueries({ queryKey: ["planes", "list"] }); + qc.setQueryData(qk.plan(plan.id), plan); + }, + }); +} + +export function useClonePlan() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: plans_clone_from_existing, + onSuccess: (plan) => { + qc.invalidateQueries({ queryKey: ["planes", "list"] }); + qc.setQueryData(qk.plan(plan.id), plan); + }, + }); +} + +export function useImportPlanFromFiles() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: plans_import_from_files, + onSuccess: (plan) => { + qc.invalidateQueries({ queryKey: ["planes", "list"] }); + qc.setQueryData(qk.plan(plan.id), plan); + }, + }); +} + +export function useUpdatePlanFields() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) => + plans_update_fields(vars.planId, vars.patch), + onSuccess: (updated) => { + qc.setQueryData(qk.plan(updated.id), updated); + qc.invalidateQueries({ queryKey: ["planes", "list"] }); + qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) }); + }, + }); +} + +export function useUpdatePlanMapa() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { planId: UUID; ops: PlanMapOperation[] }) => plans_update_map(vars.planId, vars.ops), + + // ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA + onMutate: async (vars) => { + await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) }); + const prev = qc.getQueryData(qk.planAsignaturas(vars.planId)); + + // solo optimizamos MOVEs simples + const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA") as Array< + Extract + >; + + if (prev && Array.isArray(prev) && moves.length) { + const next = prev.map((a: any) => { + const m = moves.find((x) => x.asignaturaId === a.id); + if (!m) return a; + return { + ...a, + numero_ciclo: m.numero_ciclo, + linea_plan_id: m.linea_plan_id, + orden_celda: m.orden_celda ?? a.orden_celda, + }; + }); + qc.setQueryData(qk.planAsignaturas(vars.planId), next); + } + + return { prev }; + }, + + onError: (_err, vars, ctx) => { + if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev); + }, + + onSuccess: (_ok, vars) => { + qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) }); + qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) }); + }, + }); +} + +export function useTransitionPlanEstado() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: plans_transition_state, + onSuccess: (_ok, vars) => { + qc.invalidateQueries({ queryKey: qk.plan(vars.planId) }); + qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) }); + qc.invalidateQueries({ queryKey: ["planes", "list"] }); + }, + }); +} + +export function useGeneratePlanDocumento() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (planId: UUID) => plans_generate_document(planId), + onSuccess: (_doc, planId) => { + qc.invalidateQueries({ queryKey: qk.planDocumento(planId) }); + qc.invalidateQueries({ queryKey: qk.planHistorial(planId) }); + }, + }); +} diff --git a/src/data/hooks/useSubjects.ts b/src/data/hooks/useSubjects.ts new file mode 100644 index 0000000..37e87e4 --- /dev/null +++ b/src/data/hooks/useSubjects.ts @@ -0,0 +1,166 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../query/keys"; +import type { UUID } from "../types/domain"; +import type { + BibliografiaUpsertInput, + SubjectsCreateManualInput, + SubjectsUpdateFieldsPatch, +} from "../api/subjects.api"; +import { + ai_generate_subject, + subjects_bibliografia_list, + subjects_clone_from_existing, + subjects_create_manual, + subjects_generate_document, + subjects_get, + subjects_get_document, + subjects_history, + subjects_import_from_file, + subjects_persist_from_ai, + subjects_update_bibliografia, + subjects_update_contenido, + subjects_update_fields, +} from "../api/subjects.api"; + +export function useSubject(subjectId: UUID | null | undefined) { + return useQuery({ + queryKey: subjectId ? qk.asignatura(subjectId) : ["asignaturas", "detail", null], + queryFn: () => subjects_get(subjectId as UUID), + enabled: Boolean(subjectId), + }); +} + +export function useSubjectBibliografia(subjectId: UUID | null | undefined) { + return useQuery({ + queryKey: subjectId ? qk.asignaturaBibliografia(subjectId) : ["asignaturas", "bibliografia", null], + queryFn: () => subjects_bibliografia_list(subjectId as UUID), + enabled: Boolean(subjectId), + }); +} + +export function useSubjectHistorial(subjectId: UUID | null | undefined) { + return useQuery({ + queryKey: subjectId ? qk.asignaturaHistorial(subjectId) : ["asignaturas", "historial", null], + queryFn: () => subjects_history(subjectId as UUID), + enabled: Boolean(subjectId), + }); +} + +export function useSubjectDocumento(subjectId: UUID | null | undefined) { + return useQuery({ + queryKey: subjectId ? qk.asignaturaDocumento(subjectId) : ["asignaturas", "documento", null], + queryFn: () => subjects_get_document(subjectId as UUID), + enabled: Boolean(subjectId), + staleTime: 30_000, + }); +} + +/* ------------------ Mutations ------------------ */ + +export function useCreateSubjectManual() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (payload: SubjectsCreateManualInput) => subjects_create_manual(payload), + onSuccess: (subject) => { + qc.setQueryData(qk.asignatura(subject.id), subject); + qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); + qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); + }, + }); +} + +export function useGenerateSubjectAI() { + return useMutation({ mutationFn: ai_generate_subject }); +} + +export function usePersistSubjectFromAI() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (payload: { planId: UUID; jsonMateria: any }) => subjects_persist_from_ai(payload), + onSuccess: (subject) => { + qc.setQueryData(qk.asignatura(subject.id), subject); + qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); + qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); + }, + }); +} + +export function useCloneSubject() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: subjects_clone_from_existing, + onSuccess: (subject) => { + qc.setQueryData(qk.asignatura(subject.id), subject); + qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); + qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); + }, + }); +} + +export function useImportSubjectFromFile() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: subjects_import_from_file, + onSuccess: (subject) => { + qc.setQueryData(qk.asignatura(subject.id), subject); + qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) }); + qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) }); + }, + }); +} + +export function useUpdateSubjectFields() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) => + subjects_update_fields(vars.subjectId, vars.patch), + onSuccess: (updated) => { + qc.setQueryData(qk.asignatura(updated.id), updated); + qc.invalidateQueries({ queryKey: qk.planAsignaturas(updated.plan_estudio_id) }); + qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) }); + }, + }); +} + +export function useUpdateSubjectContenido() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { subjectId: UUID; unidades: any[] }) => + subjects_update_contenido(vars.subjectId, vars.unidades), + onSuccess: (updated) => { + qc.setQueryData(qk.asignatura(updated.id), updated); + qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) }); + }, + }); +} + +export function useUpdateSubjectBibliografia() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) => + subjects_update_bibliografia(vars.subjectId, vars.entries), + onSuccess: (_ok, vars) => { + qc.invalidateQueries({ queryKey: qk.asignaturaBibliografia(vars.subjectId) }); + qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) }); + }, + }); +} + +export function useGenerateSubjectDocumento() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId), + onSuccess: (_doc, subjectId) => { + qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) }); + qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) }); + }, + }); +} diff --git a/src/data/hooks/useTasks.ts b/src/data/hooks/useTasks.ts new file mode 100644 index 0000000..6ad262b --- /dev/null +++ b/src/data/hooks/useTasks.ts @@ -0,0 +1,22 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../query/keys"; +import { tareas_marcar_completada, tareas_mias_list } from "../api/tasks.api"; + +export function useMisTareas() { + return useQuery({ + queryKey: qk.tareas(), + queryFn: tareas_mias_list, + staleTime: 15_000, + }); +} + +export function useMarcarTareaCompletada() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: tareas_marcar_completada, + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.tareas() }); + }, + }); +} diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 0000000..cfb7066 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,23 @@ +export * from "./supabase/client"; +export * from "./supabase/invokeEdge"; + +export * from "./query/queryClient"; +export * from "./query/keys"; + +export * from "./types/domain"; + +export * from "./api/meta.api"; +export * from "./api/plans.api"; +export * from "./api/subjects.api"; +export * from "./api/files.api"; +export * from "./api/ai.api"; +export * from "./api/tasks.api"; +export * from "./api/notifications.api"; + +export * from "./hooks/useAuth"; +export * from "./hooks/useMeta"; +export * from "./hooks/usePlans"; +export * from "./hooks/useSubjects"; +export * from "./hooks/useAI"; +export * from "./hooks/useTasks"; +export * from "./hooks/useNotifications"; diff --git a/src/data/query/keys.ts b/src/data/query/keys.ts new file mode 100644 index 0000000..788da9f --- /dev/null +++ b/src/data/query/keys.ts @@ -0,0 +1,31 @@ +export const qk = { + auth: ["auth"] as const, + session: () => ["auth", "session"] as const, + meProfile: () => ["auth", "meProfile"] as const, + + facultades: () => ["meta", "facultades"] as const, + carreras: (facultadId?: string | null) => + ["meta", "carreras", { facultadId: facultadId ?? null }] as const, + estructurasPlan: (nivel?: string | null) => + ["meta", "estructurasPlan", { nivel: nivel ?? null }] as const, + estructurasAsignatura: () => ["meta", "estructurasAsignatura"] as const, + estadosPlan: () => ["meta", "estadosPlan"] as const, + + planesList: (filters: unknown) => ["planes", "list", filters] as const, + plan: (planId: string) => ["planes", "detail", planId] as const, + planLineas: (planId: string) => ["planes", planId, "lineas"] as const, + planAsignaturas: (planId: string) => ["planes", planId, "asignaturas"] as const, + planHistorial: (planId: string) => ["planes", planId, "historial"] as const, + planDocumento: (planId: string) => ["planes", planId, "documento"] as const, + + asignatura: (asignaturaId: string) => ["asignaturas", "detail", asignaturaId] as const, + asignaturaBibliografia: (asignaturaId: string) => + ["asignaturas", asignaturaId, "bibliografia"] as const, + asignaturaHistorial: (asignaturaId: string) => + ["asignaturas", asignaturaId, "historial"] as const, + asignaturaDocumento: (asignaturaId: string) => + ["asignaturas", asignaturaId, "documento"] as const, + + tareas: () => ["tareas", "mias"] as const, + notificaciones: () => ["notificaciones", "mias"] as const, +}; diff --git a/src/data/query/queryClient.ts b/src/data/query/queryClient.ts new file mode 100644 index 0000000..6bf9c4a --- /dev/null +++ b/src/data/query/queryClient.ts @@ -0,0 +1,14 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: (failureCount) => failureCount < 2, + }, + mutations: { + retry: 0, + }, + }, +}); diff --git a/src/data/supabase/client.ts b/src/data/supabase/client.ts new file mode 100644 index 0000000..6e707bc --- /dev/null +++ b/src/data/supabase/client.ts @@ -0,0 +1,31 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "../types/database"; +import { getEnv } from "./env"; + +let _client: SupabaseClient | null = null; + +export function supabaseBrowser(): SupabaseClient { + if (_client) return _client; + + const url = getEnv( + "VITE_SUPABASE_URL", + "NEXT_PUBLIC_SUPABASE_URL", + "SUPABASE_URL" + ); + + const anonKey = getEnv( + "VITE_SUPABASE_ANON_KEY", + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + "SUPABASE_ANON_KEY" + ); + + _client = createClient(url, anonKey, { + auth: { + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: true, + }, + }); + + return _client; +} diff --git a/src/data/supabase/env.ts b/src/data/supabase/env.ts new file mode 100644 index 0000000..03c6e16 --- /dev/null +++ b/src/data/supabase/env.ts @@ -0,0 +1,17 @@ +export function getEnv(...keys: string[]): string { + for (const key of keys) { + const fromProcess = + typeof process !== "undefined" ? (process as any).env?.[key] : undefined; + + // Vite / bundlers + const fromImportMeta = + typeof import.meta !== "undefined" ? (import.meta as any).env?.[key] : undefined; + + const value = fromProcess ?? fromImportMeta; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + + throw new Error( + `Falta variable de entorno. Probé: ${keys.join(", ")}` + ); +} diff --git a/src/data/supabase/invokeEdge.ts b/src/data/supabase/invokeEdge.ts new file mode 100644 index 0000000..289a873 --- /dev/null +++ b/src/data/supabase/invokeEdge.ts @@ -0,0 +1,47 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "../types/database"; +import { supabaseBrowser } from "./client"; + +export type EdgeInvokeOptions = { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; +}; + +export class EdgeFunctionError extends Error { + constructor( + message: string, + public readonly functionName: string, + public readonly status?: number, + public readonly details?: unknown + ) { + super(message); + this.name = "EdgeFunctionError"; + } +} + +export async function invokeEdge( + functionName: string, + body?: unknown, + opts: EdgeInvokeOptions = {}, + client?: SupabaseClient +): Promise { + const supabase = client ?? supabaseBrowser(); + + const { data, error } = await supabase.functions.invoke(functionName, { + body, + method: opts.method ?? "POST", + headers: opts.headers, + }); + + if (error) { + const anyErr = error as any; + throw new EdgeFunctionError( + anyErr.message ?? "Error en Edge Function", + functionName, + anyErr.status, + anyErr + ); + } + + return data as TOut; +} diff --git a/src/data/types/database.ts b/src/data/types/database.ts new file mode 100644 index 0000000..4c0c381 --- /dev/null +++ b/src/data/types/database.ts @@ -0,0 +1,9 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json } + | Json[]; + +export type Database = any; // ✅ Reemplaza por tipos generados (supabase gen types typescript) diff --git a/src/data/types/domain.ts b/src/data/types/domain.ts new file mode 100644 index 0000000..cb568a4 --- /dev/null +++ b/src/data/types/domain.ts @@ -0,0 +1,275 @@ +import type { Json } from "./database"; + +export type UUID = string; + +export type TipoEstructuraPlan = "CURRICULAR" | "NO_CURRICULAR"; +export type NivelPlanEstudio = + | "LICENCIATURA" + | "MAESTRIA" + | "DOCTORADO" + | "ESPECIALIDAD" + | "DIPLOMADO" + | "OTRO"; + +export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE" | "OTRO"; + +export type TipoOrigen = "MANUAL" | "IA" | "CLONADO_INTERNO" | "TRADICIONAL" | "OTRO"; + +export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA"; + +export type TipoBibliografia = "BASICA" | "COMPLEMENTARIA"; +export type TipoFuenteBibliografia = "MANUAL" | "BIBLIOTECA"; + +export type EstadoTareaRevision = "PENDIENTE" | "COMPLETADA" | "OMITIDA"; +export type TipoNotificacion = "PLAN_ASIGNADO" | "ESTADO_CAMBIADO" | "TAREA_ASIGNADA" | "COMENTARIO" | "OTRA"; + +export type TipoInteraccionIA = "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA"; + +export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta"; +export type DisenoCurricular = "Rígido" | "Flexible"; + +/** Basado en tu schema JSON (va típicamente dentro de planes_estudio.datos) */ +export type PlanDatosSep = { + nivel?: string; + nombre?: string; + modalidad_educativa?: ModalidadEducativa; + + antecedente_academico?: string; + area_de_estudio?: string; + clave_del_plan_de_estudios?: string; + + diseno_curricular?: DisenoCurricular; + + total_de_ciclos_del_plan_de_estudios?: string; + duracion_del_ciclo_escolar?: string; + carga_horaria_a_la_semana?: number; + + fines_de_aprendizaje_o_formacion?: string; + perfil_de_egreso?: string; + + programa_de_investigacion?: string | null; + curso_propedeutico?: string | null; + + perfil_de_ingreso?: string; + + administracion_y_operatividad_del_plan_de_estudios?: string | null; + sustento_teorico_del_modelo_curricular?: string | null; + justificacion_de_la_propuesta_curricular?: string | null; + propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null; +}; + +export type Paged = { data: T[]; count: number | null }; + +export type Facultad = { + id: UUID; + nombre: string; + nombre_corto: string | null; + color: string | null; + icono: string | null; + creado_en: string; + actualizado_en: string; +}; + +export type Carrera = { + id: UUID; + facultad_id: UUID; + nombre: string; + nombre_corto: string | null; + clave_sep: string | null; + activa: boolean; + creado_en: string; + actualizado_en: string; + + facultades?: Facultad | null; +}; + +export type EstructuraPlan = { + id: UUID; + nombre: string; + tipo: TipoEstructuraPlan; + version: string | null; + definicion: Json; +}; + +export type EstructuraAsignatura = { + id: UUID; + nombre: string; + version: string | null; + definicion: Json; +}; + +export type EstadoPlan = { + id: UUID; + clave: string; + etiqueta: string; + orden: number; + es_final: boolean; +}; + +export type PlanEstudio = { + id: UUID; + carrera_id: UUID; + estructura_id: UUID; + + nombre: string; + nivel: NivelPlanEstudio; + tipo_ciclo: TipoCiclo; + numero_ciclos: number; + + datos: Json; + + estado_actual_id: UUID | null; + activo: boolean; + + tipo_origen: TipoOrigen | null; + meta_origen: Json; + + creado_por: UUID | null; + actualizado_por: UUID | null; + + creado_en: string; + actualizado_en: string; + + carreras?: Carrera | null; + estructuras_plan?: EstructuraPlan | null; + estados_plan?: EstadoPlan | null; +}; + +export type LineaPlan = { + id: UUID; + plan_estudio_id: UUID; + nombre: string; + orden: number; + area: string | null; + creado_en: string; + actualizado_en: string; +}; + +export type Asignatura = { + id: UUID; + plan_estudio_id: UUID; + estructura_id: UUID | null; + + facultad_propietaria_id: UUID | null; + + codigo: string | null; + nombre: string; + + tipo: TipoAsignatura; + creditos: number; + horas_semana: number | null; + + numero_ciclo: number | null; + linea_plan_id: UUID | null; + orden_celda: number | null; + + datos: Json; + contenido_tematico: Json; + + tipo_origen: TipoOrigen | null; + meta_origen: Json; + + creado_por: UUID | null; + actualizado_por: UUID | null; + + creado_en: string; + actualizado_en: string; + + planes_estudio?: PlanEstudio | null; + estructuras_asignatura?: EstructuraAsignatura | null; +}; + +export type BibliografiaAsignatura = { + id: UUID; + asignatura_id: UUID; + tipo: TipoBibliografia; + cita: string; + tipo_fuente: TipoFuenteBibliografia; + biblioteca_item_id: string | null; + + creado_por: UUID | null; + creado_en: string; + actualizado_en: string; +}; + +export type CambioPlan = { + id: UUID; + plan_estudio_id: UUID; + cambiado_por: UUID | null; + cambiado_en: string; + tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO"; + campo: string | null; + valor_anterior: Json | null; + valor_nuevo: Json | null; + interaccion_ia_id: UUID | null; +}; + +export type CambioAsignatura = { + id: UUID; + asignatura_id: UUID; + cambiado_por: UUID | null; + cambiado_en: string; + tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO"; + campo: string | null; + valor_anterior: Json | null; + valor_nuevo: Json | null; + fuente: "HUMANO" | "IA" | null; + interaccion_ia_id: UUID | null; +}; + +export type InteraccionIA = { + id: UUID; + usuario_id: UUID | null; + plan_estudio_id: UUID | null; + asignatura_id: UUID | null; + + tipo: TipoInteraccionIA; + modelo: string | null; + temperatura: number | null; + + prompt: Json; + respuesta: Json; + + aceptada: boolean; + + conversacion_id: string | null; + ids_archivos: Json; + ids_vector_store: Json; + + creado_en: string; +}; + +export type TareaRevision = { + id: UUID; + plan_estudio_id: UUID; + asignado_a: UUID; + rol_id: UUID | null; + estado_id: UUID | null; + estatus: EstadoTareaRevision; + fecha_limite: string | null; + creado_en: string; + completado_en: string | null; +}; + +export type Notificacion = { + id: UUID; + usuario_id: UUID; + tipo: TipoNotificacion; + payload: Json; + leida: boolean; + creado_en: string; + leida_en: string | null; +}; + +export type Archivo = { + id: UUID; + ruta_storage: string; + nombre: string; + mime_type: string | null; + bytes: number | null; + subido_por: UUID | null; + subido_en: string; + temporal: boolean; + openai_file_id: string | null; + notas: string | null; +};