1 Commits

Author SHA1 Message Date
7e1045358d Login de usuarios wip
Lo que ya sirve:
- Ya se puede hacer login con email y contraseña
- Se puede hacer logout con un botón en el header
- La página te redirige a login si no hay sesion
- La página te redirige a dashboard desde login si hay sesión

Lo que falta:
- Comprobar si se atrapan y manejan correctamente los errores por violación a RLS
- Cambiar la BDD para asignar roles y permisos a usuarios
- Comprobar si de manera defensiva se reestablecen los roles/permisos cuando el usuario intenta hacer algo que no está permitido
2026-03-04 12:16:48 -06:00
17 changed files with 467 additions and 235 deletions

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
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-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9", "@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.98.0",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5", "@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=="], "@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.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="], "@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="], "@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/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="], "@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
"@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/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/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="], "@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
"@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=="], "@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=="],
"@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/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,6 +10,7 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
@@ -17,11 +18,11 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide",
"registries": { "registries": {
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json", "@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json", "@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json", "@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json" "@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json",
"@supabase": "https://supabase.com/ui/r/{name}.json"
} }
} }

View File

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

View File

@@ -1,9 +1,20 @@
import { Link } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { Home, Menu, Network, X } from 'lucide-react' import { Home, LogOut, Menu, Network, X } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { supabaseBrowser } from '@/data/supabase/client'
export default function Header() { export default function Header() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const navigate = useNavigate()
const handleLogout = async () => {
try {
await supabaseBrowser().auth.signOut()
} finally {
void navigate({ to: '/login', replace: true })
}
}
return ( return (
<> <>
@@ -21,6 +32,16 @@ export default function Header() {
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" /> <img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
</Link> </Link>
</h1> </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> </header>
<aside <aside

View File

@@ -1,18 +1,44 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput' import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton' 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() { export function ExternalLoginForm() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = 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 () => { const submit = async () => {
/* await supabase.auth.signInWithPassword({ setIsLoading(true)
email, setError(null)
password,
})*/ try {
const { error } = 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 ( return (
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
value={password} value={password}
onChange={setPassword} onChange={setPassword}
/> />
<SubmitButton /> {error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form> </form>
) )
} }

View File

@@ -1,18 +1,45 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput' import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton' 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() { export function InternalLoginForm() {
const [clave, setClave] = useState('') const [clave, setClave] = useState('')
const [password, setPassword] = 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 () => { const submit = async () => {
/* await supabase.auth.signInWithPassword({ setIsLoading(true)
email: `${clave}@ulsa.mx`, setError(null)
password,
})*/ try {
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
const { error } = 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 ( return (
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
value={password} value={password}
onChange={setPassword} onChange={setPassword}
/> />
<SubmitButton /> {error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form> </form>
) )
} }

View File

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

View File

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

View File

@@ -100,7 +100,7 @@ export async function library_search(payload: {
export async function create_conversation(planId: string) { export async function create_conversation(planId: string) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/plan/conversations', 'create-chat-conversation/conversations',
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: {
}): Promise<{ reply: string; meta?: any }> { }): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`, `create-chat-conversation/conversations/${payload.conversacionId}/messages`,
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -175,22 +175,6 @@ export async function getConversationByPlan(planId: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? [] 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( export async function update_conversation_title(
conversacionId: string, conversacionId: string,
@@ -210,40 +194,45 @@ export async function update_conversation_title(
} }
export async function update_recommendation_applied_status( export async function update_recommendation_applied_status(
mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente conversacionId: string,
campoAfectado: string, campoAfectado: string,
) { ) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
// 1. Obtener la propuesta actual de ese mensaje específico // 1. Obtener el estado actual del JSON
const { data: msgData, error: fetchError } = await supabase const { data: conv, error: fetchError } = await supabase
.from('plan_mensajes_ia') .from('conversaciones_plan')
.select('propuesta') .select('conversacion_json')
.eq('id', mensajeId) .eq('id', conversacionId)
.single() .single()
if (fetchError) throw fetchError if (fetchError) throw fetchError
if (!msgData?.propuesta) if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
throw new Error('No se encontró la propuesta en el mensaje')
const propuestaActual = msgData.propuesta as any // 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,
),
}
}
return msg
})
// 2. Modificar el array de recommendations dentro de la propuesta // 3. Actualizar la base de datos con el nuevo JSON
// Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto const { data, error: updateError } = await supabase
const nuevaPropuesta = { .from('conversaciones_plan')
...propuestaActual, .update({ conversacion_json: nuevoJson })
recommendations: (propuestaActual.recommendations || []).map((rec: any) => .eq('id', conversacionId)
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec, .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 if (updateError) throw updateError
return data
return true
} }

View File

@@ -12,7 +12,6 @@ import {
update_conversation_status, update_conversation_status,
update_recommendation_applied_status, update_recommendation_applied_status,
update_conversation_title, update_conversation_title,
getMessagesByConversation,
} from '../api/ai.api' } from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol // eslint-disable-next-line node/prefer-node-protocol
@@ -89,25 +88,6 @@ 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() { export function useUpdateRecommendationApplied() {
const qc = useQueryClient() const qc = useQueryClient()

View File

@@ -1,59 +1,145 @@
import { useEffect } from "react"; import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from 'react'
import { supabaseBrowser } from "../supabase/client";
import { qk } from "../query/keys"; import { throwIfError } from '../api/_helpers'
import { throwIfError } from "../api/_helpers"; import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
export function useSession() { export function useSession() {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
const qc = useQueryClient(); const qc = useQueryClient()
const query = useQuery({ const query = useQuery({
queryKey: qk.session(), queryKey: qk.session(),
queryFn: async () => { queryFn: async () => {
const { data, error } = await supabase.auth.getSession(); const { data, error } = await supabase.auth.getSession()
throwIfError(error); throwIfError(error)
return data.session ?? null; return data.session ?? null
}, },
staleTime: Infinity, staleTime: Infinity,
}); })
useEffect(() => { useEffect(() => {
const { data } = supabase.auth.onAuthStateChange(() => { const { data } = supabase.auth.onAuthStateChange(() => {
qc.invalidateQueries({ queryKey: qk.session() }); qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.meProfile() }); qc.invalidateQueries({ queryKey: qk.meProfile() })
qc.invalidateQueries({ queryKey: qk.auth }); qc.invalidateQueries({ queryKey: qk.meAccess() })
}); qc.invalidateQueries({ queryKey: qk.auth })
})
return () => data.subscription.unsubscribe(); return () => data.subscription.unsubscribe()
}, [supabase, qc]); }, [supabase, qc])
return query; return query
} }
export function useMeProfile() { export function useMeProfile() {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
return useQuery({ return useQuery({
queryKey: qk.meProfile(), queryKey: qk.meProfile(),
queryFn: async () => { queryFn: async () => {
const { data: u, error: uErr } = await supabase.auth.getUser(); const { data: u, error: uErr } = await supabase.auth.getUser()
throwIfError(uErr); throwIfError(uErr)
const userId = u.user?.id; const userId = u.user?.id
if (!userId) return null; if (!userId) return null
const { data, error } = await supabase const { data, error } = await supabase
.from("usuarios_app") .from('usuarios_app')
.select("id,nombre_completo,email,externo,creado_en,actualizado_en") .select('id,nombre_completo,email,externo,creado_en,actualizado_en')
.eq("id", userId) .eq('id', userId)
.single(); .single()
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo) // 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); throwIfError(error)
return data ?? null; return data ?? null
}, },
staleTime: 60_000, 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,6 +2,7 @@ export const qk = {
auth: ['auth'] as const, auth: ['auth'] as const,
session: () => ['auth', 'session'] as const, session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const, meProfile: () => ['auth', 'meProfile'] as const,
meAccess: () => ['auth', 'meAccess'] as const,
facultades: () => ['meta', 'facultades'] as const, facultades: () => ['meta', 'facultades'] as const,
carreras: (facultadId?: string | null) => carreras: (facultadId?: string | null) =>

View File

@@ -1,20 +1,57 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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'
}
export function getContext() { export function getContext() {
const queryClient = new QueryClient( const queryClientRef: { current: QueryClient | null } = { current: null }
{
defaultOptions: { const handleAuthzDesync = (error: unknown) => {
queries: { if (!isRlsViolationError(error)) return
staleTime: 30_000, // Forzar resincronización “database-first” del rol/permisos
refetchOnWindowFocus: false, console.log('RLS violation detected, invalidating queries...')
retry: (failureCount) => failureCount < 2, queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
mutationCache: new MutationCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (failureCount) => failureCount < 2,
},
mutations: {
retry: 0,
},
}, },
mutations: { })
retry: 0,
}, queryClientRef.current = queryClient
},
}
)
return { return {
queryClient, queryClient,
} }

View File

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

View File

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