7 Commits

21 changed files with 270 additions and 477 deletions

View File

@@ -1 +0,0 @@
Al funcionar como agente, ignora los problemas de eslint del orden de imports

View File

@@ -20,7 +20,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.98.0",
"@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",
@@ -441,17 +441,17 @@
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.93.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg=="],
"@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
"@supabase/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.93.1", "", { "dependencies": { "@supabase/auth-js": "2.93.1", "@supabase/functions-js": "2.93.1", "@supabase/postgrest-js": "2.93.1", "@supabase/realtime-js": "2.93.1", "@supabase/storage-js": "2.93.1" } }, "sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg=="],
"@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=="],

View File

@@ -10,7 +10,6 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -18,11 +17,11 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"registries": {
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json",
"@supabase": "https://supabase.com/ui/r/{name}.json"
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json"
}
}

View File

@@ -33,7 +33,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.98.0",
"@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",

View File

@@ -1,20 +1,9 @@
import { Link, useNavigate } from '@tanstack/react-router'
import { Home, LogOut, Menu, Network, X } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Home, Menu, Network, X } from 'lucide-react'
import { useState } from 'react'
import { supabaseBrowser } from '@/data/supabase/client'
export default function Header() {
const [isOpen, setIsOpen] = useState(false)
const navigate = useNavigate()
const handleLogout = async () => {
try {
await supabaseBrowser().auth.signOut()
} finally {
void navigate({ to: '/login', replace: true })
}
}
return (
<>
@@ -32,16 +21,6 @@ export default function Header() {
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
</Link>
</h1>
<button
onClick={handleLogout}
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
aria-label="Logout"
title="Logout"
>
<LogOut size={20} />
<span className="hidden sm:inline">Salir</span>
</button>
</header>
<aside

View File

@@ -1,44 +1,18 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function ExternalLoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => {
setIsLoading(true)
setError(null)
try {
const { error } = await supabase.auth.signInWithPassword({
/* await supabase.auth.signInWithPassword({
email,
password,
})
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
})*/
}
return (
@@ -60,11 +34,7 @@ export function ExternalLoginForm() {
value={password}
onChange={setPassword}
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
<SubmitButton />
</form>
)
}

View File

@@ -1,45 +1,18 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function InternalLoginForm() {
const [clave, setClave] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => {
setIsLoading(true)
setError(null)
try {
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
const { error } = await supabase.auth.signInWithPassword({
email,
/* await supabase.auth.signInWithPassword({
email: `${clave}@ulsa.mx`,
password,
})
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
})*/
}
return (
@@ -57,11 +30,7 @@ export function InternalLoginForm() {
value={password}
onChange={setPassword}
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
<SubmitButton />
</form>
)
}

View File

@@ -8,6 +8,7 @@ export const ImprovementCard = ({
suggestions,
onApply,
planId,
dbMessageId,
currentDatos,
activeChatId,
onApplySuccess,
@@ -16,6 +17,7 @@ export const ImprovementCard = ({
onApply?: (key: string, value: string) => void
planId: string
currentDatos: any
dbMessageId: string
activeChatId: any
onApplySuccess?: (key: string) => void
}) => {
@@ -53,9 +55,11 @@ export const ImprovementCard = ({
setLocalApplied((prev) => [...prev, key])
if (onApplySuccess) onApplySuccess(key)
if (activeChatId) {
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) {
updateAppliedStatus.mutate({
conversacionId: activeChatId,
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
campoAfectado: key,
})
}

View File

@@ -1,14 +1,13 @@
interface Props {
text?: string
disabled?: boolean
}
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
return (
<button
type="submit"
disabled={disabled}
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
font-semibold hover:opacity-90 transition"
>
{text}
</button>

View File

@@ -100,7 +100,7 @@ export async function library_search(payload: {
export async function create_conversation(planId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/conversations',
'create-chat-conversation/plan/conversations',
{
method: 'POST',
body: {
@@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: {
}): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${payload.conversacionId}/messages`,
`create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`,
{
method: 'POST',
body: {
@@ -175,6 +175,22 @@ export async function getConversationByPlan(planId: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? []
}
export async function getMessagesByConversation(conversationId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('plan_mensajes_ia')
.select('*')
.eq('conversacion_plan_id', conversationId)
.order('fecha_creacion', { ascending: true }) // Ascendente para que el chat fluya en orden cronológico
if (error) {
console.error('Error al obtener mensajes:', error.message)
throw error
}
return data ?? []
}
export async function update_conversation_title(
conversacionId: string,
@@ -194,45 +210,40 @@ export async function update_conversation_title(
}
export async function update_recommendation_applied_status(
conversacionId: string,
mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente
campoAfectado: string,
) {
const supabase = supabaseBrowser()
// 1. Obtener el estado actual del JSON
const { data: conv, error: fetchError } = await supabase
.from('conversaciones_plan')
.select('conversacion_json')
.eq('id', conversacionId)
// 1. Obtener la propuesta actual de ese mensaje específico
const { data: msgData, error: fetchError } = await supabase
.from('plan_mensajes_ia')
.select('propuesta')
.eq('id', mensajeId)
.single()
if (fetchError) throw fetchError
if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
if (!msgData?.propuesta)
throw new Error('No se encontró la propuesta en el mensaje')
// 2. Transformar el JSON para marcar como aplicada la recomendación específica
// Usamos una transformación inmutable para evitar efectos secundarios
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
return {
...msg,
recommendations: msg.recommendations.map((rec: any) =>
rec.campo_afectado === campoAfectado
? { ...rec, aplicada: true }
: rec,
const propuestaActual = msgData.propuesta as any
// 2. Modificar el array de recommendations dentro de la propuesta
// Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto
const nuevaPropuesta = {
...propuestaActual,
recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
),
}
}
return msg
})
// 3. Actualizar la base de datos con el nuevo JSON
const { data, error: updateError } = await supabase
.from('conversaciones_plan')
.update({ conversacion_json: nuevoJson })
.eq('id', conversacionId)
.select()
.single()
// 3. Actualizar la base de datos con el nuevo objeto JSON
const { error: updateError } = await supabase
.from('plan_mensajes_ia')
.update({ propuesta: nuevaPropuesta })
.eq('id', mensajeId)
if (updateError) throw updateError
return data
return true
}

View File

@@ -3,9 +3,15 @@
const DOCUMENT_PDF_URL =
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
const DOCUMENT_PDF_ASIGNATURA_URL =
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
interface GeneratePdfParams {
plan_estudio_id: string
}
interface GeneratePdfParamsAsignatura {
asignatura_id: string
}
export async function fetchPlanPdf({
plan_estudio_id,
@@ -25,3 +31,22 @@ export async function fetchPlanPdf({
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
}
export async function fetchAsignaturaPdf({
asignatura_id,
}: GeneratePdfParamsAsignatura): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asignatura_id }),
})
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
}

View File

@@ -117,7 +117,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
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)
estructuras_asignatura(id,nombre,definicion)
`,
)
.eq('id', subjectId)

View File

@@ -12,6 +12,7 @@ import {
update_conversation_status,
update_recommendation_applied_status,
update_conversation_title,
getMessagesByConversation,
} from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol
@@ -88,6 +89,25 @@ export function useConversationByPlan(planId: string | null) {
})
}
export function useMessagesByChat(conversationId: string | null) {
return useQuery({
// La queryKey debe ser única; incluimos el ID para que se refresque al cambiar de chat
queryKey: ['conversation-messages', conversationId],
// Solo ejecutamos la función si el ID no es null o undefined
queryFn: () => {
if (!conversationId) throw new Error('Conversation ID is required')
return getMessagesByConversation(conversationId)
},
// Importante: 'enabled' controla que no se dispare la petición si no hay ID
enabled: !!conversationId,
// Opcional: Mantener los datos previos mientras se carga la nueva conversación
placeholderData: (previousData) => previousData,
})
}
export function useUpdateRecommendationApplied() {
const qc = useQueryClient()

View File

@@ -1,145 +1,59 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { throwIfError } from '../api/_helpers'
import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
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 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
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.meAccess() })
qc.invalidateQueries({ queryKey: qk.auth })
})
qc.invalidateQueries({ queryKey: qk.session() });
qc.invalidateQueries({ queryKey: qk.meProfile() });
qc.invalidateQueries({ queryKey: qk.auth });
});
return () => data.subscription.unsubscribe()
}, [supabase, qc])
return () => data.subscription.unsubscribe();
}, [supabase, qc]);
return query
return query;
}
export function useMeProfile() {
const supabase = supabaseBrowser()
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: 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()
.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
if (error && (error as any).code === "PGRST116") return null;
throwIfError(error)
return data ?? null
throwIfError(error);
return data ?? null;
},
staleTime: 60_000,
})
}
export type MeAccessRole = {
assignmentId: string
rolId: string
clave: string
nombre: string
descripcion: string | null
facultadId: string | null
carreraId: string | null
}
export type MeAccess = {
userId: string
roles: Array<MeAccessRole>
permissions: Array<string>
}
/**
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
*
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
*/
export function useMeAccess() {
const supabase = supabaseBrowser()
return useQuery({
queryKey: qk.meAccess(),
queryFn: async (): Promise<MeAccess | null> => {
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_roles')
.select(
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
)
.eq('usuario_id', userId)
throwIfError(error)
const roles: Array<MeAccessRole> = (data ?? [])
.map((row: any) => {
const rol = row.roles
if (!rol) return null
return {
assignmentId: row.id,
rolId: rol.id,
clave: rol.clave,
nombre: rol.nombre,
descripcion: rol.descripcion ?? null,
facultadId: row.facultad_id ?? null,
carreraId: row.carrera_id ?? null,
} satisfies MeAccessRole
})
.filter(Boolean) as Array<MeAccessRole>
// Por ahora, los permisos granulares se derivan de claves de rol.
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
return {
userId,
roles,
permissions,
}
},
staleTime: 30_000,
refetchOnWindowFocus: true,
})
}
export function useAuth() {
const session = useSession()
const meProfile = useMeProfile()
const meAccess = useMeAccess()
return {
session,
meProfile,
meAccess,
}
});
}

View File

@@ -2,7 +2,6 @@ export const qk = {
auth: ['auth'] as const,
session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const,
meAccess: () => ['auth', 'meAccess'] as const,
facultades: () => ['meta', 'facultades'] as const,
carreras: (facultadId?: string | null) =>

View File

@@ -1,44 +1,8 @@
import {
MutationCache,
QueryCache,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { qk } from './keys'
import type React from 'react'
function isRlsViolationError(error: unknown): boolean {
const anyErr = error as any
const code = anyErr?.code
const status = anyErr?.status ?? anyErr?.response?.status
console.log('Checking RLS violation error:', { code, status })
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
return status === 403 || code === '42501'
}
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export function getContext() {
const queryClientRef: { current: QueryClient | null } = { current: null }
const handleAuthzDesync = (error: unknown) => {
if (!isRlsViolationError(error)) return
// Forzar resincronización “database-first” del rol/permisos
console.log('RLS violation detected, invalidating queries...')
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
mutationCache: new MutationCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
const queryClient = new QueryClient(
{
defaultOptions: {
queries: {
staleTime: 30_000,
@@ -49,9 +13,8 @@ export function getContext() {
retry: 0,
},
},
})
queryClientRef.current = queryClient
}
)
return {
queryClient,
}

View File

@@ -6,7 +6,6 @@ import reportWebVitals from './reportWebVitals.ts'
import { routeTree } from './routeTree.gen'
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
import { supabaseBrowser } from '@/data/supabase/client'
import './styles.css'
@@ -17,7 +16,6 @@ const router = createRouter({
routeTree,
context: {
...TanStackQueryProviderContext,
supabase: supabaseBrowser(),
},
defaultPreload: 'intent',
scrollRestoration: true,

View File

@@ -1,59 +1,22 @@
import { TanStackDevtools } from '@tanstack/react-devtools'
import {
Outlet,
createRootRouteWithContext,
redirect,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { useEffect } from 'react'
import Header from '../components/Header'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { QueryClient } from '@tanstack/react-query'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { throwIfError } from '@/data/api/_helpers'
import { useSession } from '@/data/hooks/useAuth'
import { qk } from '@/data/query/keys'
interface MyRouterContext {
queryClient: QueryClient
supabase: SupabaseClient<Database>
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context, location }) => {
const pathname = location.pathname
const isLogin = pathname === '/login'
const isIndex = pathname === '/'
const session = await context.queryClient.ensureQueryData({
queryKey: qk.session(),
queryFn: async () => {
const { data, error } = await context.supabase.auth.getSession()
throwIfError(error)
return data.session ?? null
},
staleTime: Infinity,
})
if (!session && !isLogin) {
throw redirect({ to: '/login' })
}
if (session && (isLogin || isIndex)) {
throw redirect({ to: '/dashboard' })
}
},
component: () => (
<>
<AuthSync />
<MaybeHeader />
<Header />
<Outlet />
<TanStackDevtools
config={{
@@ -97,40 +60,3 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
)
},
})
function MaybeHeader() {
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
if (pathname === '/login') return null
return <Header />
}
function AuthSync() {
const { data: session, isLoading } = useSession()
// Mantiene roles/permisos sincronizados con la BD (database-first)
// useMeAccess()
const navigate = useNavigate()
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
// Reaccionar a cambios de sesión (login/logout) sin depender solo de beforeLoad.
// Nota: beforeLoad sigue siendo la línea de defensa en navegación/refresh.
useEffect(() => {
if (isLoading) return
if (!session && pathname !== '/login') {
void navigate({ to: '/login', replace: true })
return
}
if (session && pathname === '/login') {
void navigate({ to: '/dashboard', replace: true })
}
}, [isLoading, session, pathname, navigate])
return null
}

View File

@@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea'
import {
useAIPlanChat,
useConversationByPlan,
useMessagesByChat,
useUpdateConversationStatus,
useUpdateConversationTitle,
} from '@/data'
@@ -103,6 +104,8 @@ function RouteComponent() {
)
const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
)
@@ -154,36 +157,32 @@ function RouteComponent() {
}, [lastConversation, activeChatId])
const chatMessages = useMemo(() => {
// 1. Si no hay ID o no hay data del chat, retornamos vacío
if (!activeChatId || !activeChatData) return []
if (!activeChatId || !mensajesDelChat) return []
const json = (activeChatData.conversacion_json ||
[]) as unknown as Array<ChatMessageJSON>
// flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
return mensajesDelChat.flatMap((msg: any) => {
const messages = []
// 2. Verificamos que 'json' sea realmente un array antes de mapear
if (!Array.isArray(json)) return []
// 1. Mensaje del Usuario
messages.push({
id: `${msg.id}-user`,
role: 'user',
content: msg.mensaje,
selectedFields: msg.campos || [], // Aquí están tus campos
})
return json.map((msg, index: number) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!msg?.user) {
return {
id: `err-${index}`,
// 2. Mensaje del Asistente (si hay respuesta)
if (msg.respuesta) {
// Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
const rawRecommendations = msg.propuesta?.recommendations || []
messages.push({
id: `${msg.id}-ai`,
dbMessageId: msg.id,
role: 'assistant',
content: '',
suggestions: [],
}
}
const isAssistant = msg.user === 'assistant'
return {
id: `${activeChatId}-${index}`,
role: isAssistant ? 'assistant' : 'user',
content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
isRefusal: isAssistant && msg.refusal === true,
suggestions:
isAssistant && msg.recommendations
? msg.recommendations.map((rec) => {
content: msg.respuesta,
isRefusal: msg.is_refusal,
suggestions: rawRecommendations.map((rec: any) => {
const fieldConfig = availableFields.find(
(f) => f.key === rec.campo_afectado,
)
@@ -195,12 +194,13 @@ function RouteComponent() {
newValue: rec.texto_mejora,
applied: rec.aplicada,
}
}),
})
: [],
}
})
}, [activeChatData, activeChatId, availableFields])
return messages
})
}, [mensajesDelChat, activeChatId, availableFields])
const scrollToBottom = () => {
if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
@@ -226,6 +226,8 @@ function RouteComponent() {
}, [lastConversation])
useEffect(() => {
console.log(mensajesDelChat)
scrollToBottom()
}, [chatMessages, isLoading])
@@ -242,30 +244,38 @@ function RouteComponent() {
}, [input, selectedFields])
useEffect(() => {
if (isLoadingConv || !lastConversation) return
if (isLoadingConv || isSending) return
const isChatStillActive = activeChats.some(
const currentChatExists = activeChats.some(
(chat) => chat.id === activeChatId,
)
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// Caso A: El chat actual ya no es válido (fue archivado o borrado)
if (activeChatId && !isChatStillActive && !isCreationMode) {
// 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
if (activeChatId && !currentChatExists && !isCreationMode) {
setActiveChatId(undefined)
setMessages([])
return // Salimos para evitar ejecuciones extra en este render
return
}
// Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar)
if (!activeChatId && activeChats.length > 0 && !isCreationMode) {
// 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
if (
!activeChatId &&
activeChats.length > 0 &&
!isCreationMode &&
chatMessages.length === 0
) {
setActiveChatId(activeChats[0].id)
}
}, [
activeChats,
activeChatId,
isLoadingConv,
isSending,
messages.length,
chatMessages.length,
])
// Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso
if (activeChats.length === 0 && activeChatId && !isCreationMode) {
setActiveChatId(undefined)
}
}, [activeChats, activeChatId, isLoadingConv, messages.length])
useEffect(() => {
const state = routerState.location.state as any
if (!state?.campo_edit || availableFields.length === 0) return
@@ -352,13 +362,16 @@ function RouteComponent() {
input: string,
fields: Array<SelectedField>,
) => {
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
// 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso)
// Esta regex ahora también limpia si el texto termina de forma natural
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ')
return `${cleaned}\n[Campos: ${fieldLabels}]`
// 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo"
return `${cleaned}: ${fieldLabels}`
}
const toggleField = (field: SelectedField) => {
@@ -388,42 +401,46 @@ function RouteComponent() {
const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields]
const finalPrompt = buildPrompt(rawText, currentFields)
setIsSending(true)
setOptimisticMessage(rawText)
setInput('')
setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
try {
const payload: any = {
planId: planId,
content: finalPrompt,
conversacionId: activeChatId || undefined,
}
if (currentFields.length > 0) {
payload.campos = currentFields.map((f) => f.key)
// Limpiar input inmediatamente para feedback visual
setInput('')
setSelectedFields([])
try {
const payload = {
planId,
content: buildPrompt(rawText, currentFields),
conversacionId: activeChatId,
campos:
currentFields.length > 0
? currentFields.map((f) => f.key)
: undefined,
}
const response = await sendChat(payload)
// IMPORTANTE: Si es un chat nuevo, actualizar el ID antes de invalidar
if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId)
}
await queryClient.invalidateQueries({
// Invalidar ambas para asegurar que la lista de la izquierda y los mensajes se
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
setOptimisticMessage(null)
}),
queryClient.invalidateQueries({
queryKey: ['conversation-messages', response.conversacionId],
}),
])
} catch (error) {
console.error('Error en el chat:', error)
// Aquí sí podrías usar un toast o un mensaje de error temporal
console.error('Error:', error)
} finally {
// 5. CRÍTICO: Detener el estado de carga SIEMPRE
setIsSending(false)
setOptimisticMessage(null)
}
@@ -666,6 +683,7 @@ function RouteComponent() {
<div className="mt-4">
<ImprovementCard
suggestions={msg.suggestions}
dbMessageId={msg.dbMessageId}
planId={planId}
currentDatos={data?.datos}
activeChatId={activeChatId}

View File

@@ -2,7 +2,7 @@ import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { fetchPlanPdf } from '@/data/api/document.api'
import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento',
@@ -11,7 +11,7 @@ export const Route = createFileRoute(
})
function RouteComponent() {
const { planId } = useParams({
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
})
@@ -23,8 +23,8 @@ function RouteComponent() {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
const url = window.URL.createObjectURL(pdfBlob)
@@ -38,7 +38,7 @@ function RouteComponent() {
} finally {
setIsLoading(false)
}
}, [planId])
}, [asignaturaId])
useEffect(() => {
loadPdfPreview()
@@ -49,8 +49,8 @@ function RouteComponent() {
}, [loadPdfPreview])
const handleDownload = async () => {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
const url = window.URL.createObjectURL(pdfBlob)

View File

@@ -232,7 +232,7 @@ function AsignaturaLayout() {
{ label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' },
{ label: 'Bibliografía', to: 'bibliografia' },
{ label: 'IA', to: 'asignaturaIa' },
{ label: 'IA', to: 'iaasignatura' },
{ label: 'Documento SEP', to: 'documento' },
{ label: 'Historial', to: 'historial' },
].map((tab) => {