Feat: Implementar sistema de notificaciones con historial por usuario
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 48s

- Crear composable useNotifications con gestión por localStorage
  - Almacenamiento separado por usuario usando UID de Authentik
  - Auto-limpieza de notificaciones mayores a 30 días
  - Sincronización automática entre pestañas
  - Filtrado por tipo, búsqueda y gestión completa

- Crear wrapper useToast para guardar toasts automáticamente
  - Intercepta todos los toasts de la aplicación
  - Guarda historial sin afectar funcionalidad existente

- Implementar endpoints de API para notificaciones del backend
  - POST /api/notifications/send para enviar notificaciones
  - GET /api/notifications/list para obtener pendientes

- Actualizar página de notificaciones con funcionalidad real
  - Búsqueda y filtros por tipo (info, warning, success, error)
  - Eliminar individual o todas las notificaciones
  - Marcar como leídas individual o todas
  - Badges de origen (toast, backend, manual)
  - Estados vacíos con mensajes informativos

- Actualizar badge del sidebar con contador dinámico
  - Muestra número real de notificaciones no leídas
  - Se oculta cuando no hay notificaciones

- Inicializar sistema en app.vue
  - Auto-inicialización al montar la app
  - Limpieza automática de notificaciones antiguas
This commit is contained in:
2025-10-30 18:03:37 -06:00
parent 0beb01c03c
commit b6dc08e599
7 changed files with 831 additions and 122 deletions

View File

@@ -13,6 +13,9 @@
// Inicializar sistema de temas // Inicializar sistema de temas
const { loadTheme, initStorageListener, cleanupStorageListener } = useTheme() const { loadTheme, initStorageListener, cleanupStorageListener } = useTheme()
// Inicializar sistema de notificaciones
const { initialize: initNotifications, cleanupOldNotifications } = useNotifications()
// Signal that the app is ready // Signal that the app is ready
onMounted(() => { onMounted(() => {
// Cargar tema guardado (o aplicar el por defecto) // Cargar tema guardado (o aplicar el por defecto)
@@ -21,6 +24,12 @@ onMounted(() => {
// Inicializar sincronización de tema entre pestañas // Inicializar sincronización de tema entre pestañas
initStorageListener() initStorageListener()
// Inicializar sistema de notificaciones
initNotifications()
// Limpiar notificaciones antiguas (> 30 días)
cleanupOldNotifications()
// Add class to HTML element to hide loading screen // Add class to HTML element to hide loading screen
if (process.client) { if (process.client) {
// Small delay to ensure everything is painted // Small delay to ensure everything is painted

View File

@@ -156,12 +156,12 @@
<template #leading> <template #leading>
<div class="w-7 h-7 rounded-md bg-[var(--brand-surface)] flex items-center justify-center relative flex-shrink-0"> <div class="w-7 h-7 rounded-md bg-[var(--brand-surface)] flex items-center justify-center relative flex-shrink-0">
<UIcon name="i-lucide-bell" class="size-3.5 text-[var(--brand-primary)]" /> <UIcon name="i-lucide-bell" class="size-3.5 text-[var(--brand-primary)]" />
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--brand-accent)] rounded-full ring-1 ring-[var(--brand-surface)]" /> <span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--brand-accent)] rounded-full ring-1 ring-[var(--brand-surface)]" />
</div> </div>
</template> </template>
<span class="text-sm font-medium text-[var(--brand-text)]">Notificaciones</span> <span class="text-sm font-medium text-[var(--brand-text)]">Notificaciones</span>
<template #trailing> <template v-if="unreadCount > 0" #trailing>
<UBadge color="neutral" variant="solid" size="xs" class="ml-auto bg-[var(--brand-accent)] text-[var(--brand-bg)]">3</UBadge> <UBadge color="neutral" variant="solid" size="xs" class="ml-auto bg-[var(--brand-accent)] text-[var(--brand-bg)]">{{ unreadCount }}</UBadge>
</template> </template>
</UButton> </UButton>
@@ -319,6 +319,9 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
const { user, isAuthenticated, logout } = useAuthentik() const { user, isAuthenticated, logout } = useAuthentik()
// Obtener el contador de notificaciones no leídas
const { unreadCount } = useNotifications()
// Computed para el avatar del usuario // Computed para el avatar del usuario
const userAvatar = computed(() => ({ const userAvatar = computed(() => ({
src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=c08040&color=1b1209&bold=true&format=svg`, src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=c08040&color=1b1209&bold=true&format=svg`,

View File

@@ -0,0 +1,267 @@
import { ref, computed, watch } from 'vue'
import type { Ref } from 'vue'
export interface Notification {
id: string
type: 'info' | 'warning' | 'success' | 'error'
title: string
message: string
timestamp: number
read: boolean
origin: 'toast' | 'backend' | 'manual'
metadata?: Record<string, any>
}
const notifications: Ref<Notification[]> = ref([])
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
// Días para retención de notificaciones
const RETENTION_DAYS = 30
const STORAGE_KEY_PREFIX = 'notifications-'
let currentUserId: string | null = null
let isInitialized = false
/**
* Obtiene la clave de localStorage para el usuario actual
*/
function getStorageKey(userId: string): string {
return `${STORAGE_KEY_PREFIX}${userId}`
}
/**
* Carga las notificaciones desde localStorage
*/
function loadNotifications(userId: string): Notification[] {
if (process.client) {
const key = getStorageKey(userId)
const stored = localStorage.getItem(key)
if (stored) {
try {
const parsed = JSON.parse(stored) as Notification[]
// Limpiar notificaciones antiguas (> RETENTION_DAYS días)
const cutoffTime = Date.now() - (RETENTION_DAYS * 24 * 60 * 60 * 1000)
return parsed.filter(n => n.timestamp > cutoffTime)
} catch (error) {
console.error('Error al parsear notificaciones:', error)
return []
}
}
}
return []
}
/**
* Guarda las notificaciones en localStorage
*/
function saveNotifications(userId: string, notifs: Notification[]): void {
if (process.client) {
const key = getStorageKey(userId)
localStorage.setItem(key, JSON.stringify(notifs))
}
}
/**
* Limpia las notificaciones del usuario actual del localStorage
*/
function clearStorageForUser(userId: string): void {
if (process.client) {
const key = getStorageKey(userId)
localStorage.removeItem(key)
}
}
/**
* Sincronización entre pestañas
*/
function setupStorageSync(userId: string): void {
if (process.client) {
const key = getStorageKey(userId)
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key && event.newValue) {
try {
notifications.value = JSON.parse(event.newValue)
} catch (error) {
console.error('Error al sincronizar notificaciones:', error)
}
}
}
window.addEventListener('storage', handleStorageChange)
// Cleanup
return () => {
window.removeEventListener('storage', handleStorageChange)
}
}
}
/**
* Composable principal para gestionar notificaciones
*/
export function useNotifications() {
const { user } = useAuthentik()
/**
* Inicializa el sistema de notificaciones para el usuario actual
*/
function initialize(): void {
if (!user.value || !user.value.uid) {
console.warn('No se puede inicializar notificaciones sin usuario autenticado')
notifications.value = []
currentUserId = null
isInitialized = false
return
}
const userId = user.value.uid
// Si cambia el usuario, limpiar y recargar
if (currentUserId !== userId) {
currentUserId = userId
notifications.value = loadNotifications(userId)
// Guardar automáticamente cuando cambien las notificaciones
watch(notifications, (newNotifications) => {
if (currentUserId) {
saveNotifications(currentUserId, newNotifications)
}
}, { deep: true })
// Configurar sincronización entre pestañas
setupStorageSync(userId)
isInitialized = true
}
}
/**
* Agrega una nueva notificación
*/
function addNotification(
notification: Omit<Notification, 'id' | 'timestamp' | 'read'>
): void {
if (!isInitialized) {
initialize()
}
const newNotification: Notification = {
...notification,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: Date.now(),
read: false
}
notifications.value.unshift(newNotification)
}
/**
* Marca una notificación como leída
*/
function markAsRead(notificationId: string): void {
const notification = notifications.value.find(n => n.id === notificationId)
if (notification) {
notification.read = true
}
}
/**
* Marca todas las notificaciones como leídas
*/
function markAllAsRead(): void {
notifications.value.forEach(n => n.read = true)
}
/**
* Elimina una notificación específica
*/
function removeNotification(notificationId: string): void {
const index = notifications.value.findIndex(n => n.id === notificationId)
if (index !== -1) {
notifications.value.splice(index, 1)
}
}
/**
* Elimina todas las notificaciones
*/
function clearAll(): void {
notifications.value = []
if (currentUserId) {
clearStorageForUser(currentUserId)
}
}
/**
* Elimina notificaciones antiguas (> RETENTION_DAYS días)
*/
function cleanupOldNotifications(): void {
const cutoffTime = Date.now() - (RETENTION_DAYS * 24 * 60 * 60 * 1000)
notifications.value = notifications.value.filter(n => n.timestamp > cutoffTime)
}
/**
* Obtiene notificaciones filtradas
*/
function getFilteredNotifications(options?: {
type?: Notification['type'][]
read?: boolean
search?: string
limit?: number
}): Notification[] {
let filtered = [...notifications.value]
if (options?.type && options.type.length > 0) {
filtered = filtered.filter(n => options.type!.includes(n.type))
}
if (options?.read !== undefined) {
filtered = filtered.filter(n => n.read === options.read)
}
if (options?.search) {
const searchLower = options.search.toLowerCase()
filtered = filtered.filter(n =>
n.title.toLowerCase().includes(searchLower) ||
n.message.toLowerCase().includes(searchLower)
)
}
if (options?.limit) {
filtered = filtered.slice(0, options.limit)
}
return filtered
}
// Auto-inicializar cuando haya usuario
if (user.value && user.value.uid) {
initialize()
}
// Re-inicializar si cambia el usuario
watch(() => user.value?.uid, (newUid) => {
if (newUid) {
initialize()
} else {
notifications.value = []
currentUserId = null
isInitialized = false
}
})
return {
notifications: computed(() => notifications.value),
unreadCount,
addNotification,
markAsRead,
markAllAsRead,
removeNotification,
clearAll,
cleanupOldNotifications,
getFilteredNotifications,
initialize
}
}

View File

@@ -0,0 +1,80 @@
/**
* Wrapper del composable useToast de Nuxt UI que intercepta
* automáticamente los toasts para guardarlos en el historial
* de notificaciones del usuario actual.
*/
interface ToastOptions {
title: string
description?: string
color?: 'success' | 'error' | 'info' | 'warning' | 'neutral' | string
icon?: string
actions?: Array<{ label: string; onClick: () => void }>
[key: string]: any
}
/**
* Mapea los colores de toast a tipos de notificación
*/
function mapColorToType(color?: string): 'info' | 'warning' | 'success' | 'error' {
if (!color) return 'info'
switch (color) {
case 'success':
case 'green':
return 'success'
case 'error':
case 'red':
return 'error'
case 'warning':
case 'yellow':
case 'orange':
return 'warning'
case 'info':
case 'blue':
case 'neutral':
default:
return 'info'
}
}
/**
* Hook para usar toasts con guardado automático en historial
*/
export function useToast() {
// Obtener el toast original de Nuxt UI desde el auto-import
const nuxtToast = (globalThis as any).useToast?.() || { add: () => {} }
const { addNotification } = useNotifications()
/**
* Agrega un toast y lo guarda en el historial
*/
function add(options: ToastOptions) {
// Llamar al toast original de Nuxt UI
nuxtToast.add(options)
// Guardar en el historial de notificaciones
try {
addNotification({
type: mapColorToType(options.color),
title: options.title,
message: options.description || '',
origin: 'toast',
metadata: {
icon: options.icon,
color: options.color,
hasActions: !!options.actions?.length
}
})
} catch (error) {
console.error('Error al guardar notificación en historial:', error)
}
}
return {
add,
// Exponer otros métodos del toast original si existen
...nuxtToast
}
}

View File

@@ -1,42 +1,98 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Notification } from '~/composables/useNotifications'
definePageMeta({ definePageMeta({
layout: 'dashboard', layout: 'dashboard',
title: 'Notificaciones' title: 'Notificaciones'
}) })
// Mock notifications for preview const {
const mockNotifications = [ notifications,
{ unreadCount,
id: 1, markAsRead,
type: 'info', markAllAsRead,
title: 'Nueva actualización disponible', removeNotification,
message: 'Se ha lanzado una nueva versión del sistema con mejoras de rendimiento.', clearAll,
time: '2 horas', getFilteredNotifications
read: false, } = useNotifications()
icon: 'i-lucide-info',
color: 'blue' // Estado de filtros y búsqueda
}, const searchQuery = ref('')
{ const selectedTypes = ref<Array<'info' | 'warning' | 'success' | 'error'>>([])
id: 2,
type: 'warning', // Opciones de tipos de notificación
title: 'Mantenimiento programado', const typeOptions = [
message: 'El sistema estará en mantenimiento el próximo domingo de 2:00 AM a 4:00 AM.', { value: 'info', label: 'Información', icon: 'i-lucide-info', color: 'blue' },
time: '1 día', { value: 'success', label: 'Éxito', icon: 'i-lucide-check-circle', color: 'green' },
read: false, { value: 'warning', label: 'Advertencia', icon: 'i-lucide-alert-triangle', color: 'amber' },
icon: 'i-lucide-alert-triangle', { value: 'error', label: 'Error', icon: 'i-lucide-x-circle', color: 'red' }
color: 'amber'
},
{
id: 3,
type: 'success',
title: 'Reporte generado',
message: 'Tu reporte mensual ha sido generado exitosamente.',
time: '3 días',
read: true,
icon: 'i-lucide-check-circle',
color: 'green'
}
] ]
// Notificaciones filtradas
const filteredNotifications = computed(() => {
return getFilteredNotifications({
type: selectedTypes.value.length > 0 ? selectedTypes.value : undefined,
search: searchQuery.value || undefined
})
})
// Verificar si hay notificaciones
const hasNotifications = computed(() => notifications.value.length > 0)
// Mapeo de tipos a iconos y colores
function getNotificationStyle(type: Notification['type']) {
const option = typeOptions.find(o => o.value === type)
return option || typeOptions[0]
}
// Formatear tiempo relativo
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return 'ahora'
if (minutes < 60) return `hace ${minutes} min`
if (hours < 24) return `hace ${hours}h`
if (days < 30) return `hace ${days} días`
return new Date(timestamp).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
}
// Manejar clic en notificación (marcar como leída)
function handleNotificationClick(notification: Notification) {
if (!notification.read) {
markAsRead(notification.id)
}
}
// Confirmar antes de eliminar todas
const toast = useToast()
function confirmClearAll() {
if (confirm('¿Estás seguro de que quieres eliminar todas las notificaciones?')) {
clearAll()
toast.add({
title: 'Notificaciones eliminadas',
description: 'Se han eliminado todas las notificaciones',
color: 'success'
})
}
}
// Eliminar notificación individual
function handleRemove(notification: Notification) {
removeNotification(notification.id)
toast.add({
title: 'Notificación eliminada',
color: 'info'
})
}
</script> </script>
<template> <template>
@@ -44,105 +100,233 @@ const mockNotifications = [
<UDashboardPanel grow> <UDashboardPanel grow>
<UDashboardNavbar <UDashboardNavbar
title="Notificaciones" title="Notificaciones"
description="Mantente al día con las últimas actualizaciones y alertas" description="Historial de notificaciones y alertas del sistema"
/> />
<UDashboardPanelContent> <UDashboardPanelContent>
<div class="max-w-4xl mx-auto space-y-8"> <div class="max-w-4xl mx-auto space-y-6">
<!-- Coming Soon Banner --> <!-- Header con acciones -->
<UCard> <div v-if="hasNotifications" class="flex flex-col sm:flex-row gap-4">
<div class="text-center py-12 space-y-6"> <!-- Búsqueda -->
<div class="flex justify-center"> <div class="flex-1">
<div class="w-24 h-24 rounded-full bg-amber-50 dark:bg-amber-950/30 flex items-center justify-center relative"> <UInput
<UIcon name="i-lucide-bell" class="size-12 text-amber-600 dark:text-amber-400" /> v-model="searchQuery"
<span class="absolute -top-1 -right-1 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center ring-4 ring-white dark:ring-gray-900"> icon="i-lucide-search"
3 placeholder="Buscar notificaciones..."
</span> size="lg"
</div> />
</div> </div>
<div> <!-- Acciones -->
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <div class="flex gap-2">
Página en construcción
</h2>
<p class="text-lg text-gray-600 dark:text-[var(--brand-text-muted)] mb-4">
Estamos trabajando en esta funcionalidad
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 max-w-md mx-auto">
Pronto podrás gestionar tus notificaciones, configurar alertas personalizadas y mantenerte informado sobre eventos importantes del sistema.
</p>
</div>
<div class="flex justify-center gap-3">
<UButton <UButton
to="/" v-if="unreadCount > 0"
color="primary" color="neutral"
icon="i-lucide-home" variant="soft"
@click="markAllAsRead"
> >
Volver al inicio <template #leading>
<UIcon name="i-lucide-check-check" />
</template>
Marcar todas como leídas
</UButton>
<UButton
color="neutral"
variant="soft"
@click="confirmClearAll"
>
<template #leading>
<UIcon name="i-lucide-trash-2" />
</template>
Eliminar todas
</UButton> </UButton>
</div> </div>
</div> </div>
</UCard>
<!-- Notifications Preview --> <!-- Filtros por tipo -->
<UCard> <div v-if="hasNotifications" class="flex gap-2 flex-wrap">
<template #header> <UButton
<div class="flex items-center justify-between"> v-for="option in typeOptions"
<h3 class="text-lg font-semibold text-gray-900 dark:text-white"> :key="option.value"
Vista previa de notificaciones :color="selectedTypes.includes(option.value as any) ? option.color : 'neutral'"
</h3> :variant="selectedTypes.includes(option.value as any) ? 'solid' : 'soft'"
<UBadge color="red" variant="solid" size="sm"> size="sm"
{{ mockNotifications.filter(n => !n.read).length }} nuevas @click="() => {
const idx = selectedTypes.indexOf(option.value as any)
if (idx === -1) {
selectedTypes.push(option.value as any)
} else {
selectedTypes.splice(idx, 1)
}
}"
>
<template #leading>
<UIcon :name="option.icon" />
</template>
{{ option.label }}
</UButton>
</div>
<!-- Badge de contador -->
<div v-if="hasNotifications" class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
{{ filteredNotifications.length }} notificación{{ filteredNotifications.length !== 1 ? 'es' : '' }}
</span>
<UBadge v-if="unreadCount > 0" color="red" variant="solid" size="sm">
{{ unreadCount }} nueva{{ unreadCount !== 1 ? 's' : '' }}
</UBadge> </UBadge>
</div> </div>
</template>
<!-- Lista de notificaciones -->
<UCard v-if="filteredNotifications.length > 0">
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="notification in mockNotifications" v-for="notification in filteredNotifications"
:key="notification.id" :key="notification.id"
:class="[ :class="[
'p-4 rounded-lg border transition-colors', 'p-4 rounded-lg border transition-all cursor-pointer group',
notification.read notification.read
? 'bg-gray-50/50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700' ? 'bg-gray-50/50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
: 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600' : 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
]" ]"
@click="handleNotificationClick(notification)"
> >
<div class="flex gap-3"> <div class="flex gap-3">
<div :class="[ <!-- Icono -->
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
`bg-${notification.color}-50 dark:bg-${notification.color}-950/30` `bg-${getNotificationStyle(notification.type).color}-50 dark:bg-${getNotificationStyle(notification.type).color}-950/30`
]"> ]"
>
<UIcon <UIcon
:name="notification.icon" :name="getNotificationStyle(notification.type).icon"
:class="[ :class="[
'size-5', 'size-5',
`text-${notification.color}-600 dark:text-${notification.color}-400` `text-${getNotificationStyle(notification.type).color}-600 dark:text-${getNotificationStyle(notification.type).color}-400`
]" ]"
/> />
</div> </div>
<!-- Contenido -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<h4 :class="[ <h4
:class="[
'font-semibold text-sm', 'font-semibold text-sm',
notification.read notification.read
? 'text-gray-600 dark:text-[var(--brand-text-muted)]' ? 'text-gray-600 dark:text-[var(--brand-text-muted)]'
: 'text-gray-900 dark:text-white' : 'text-gray-900 dark:text-white'
]"> ]"
>
{{ notification.title }} {{ notification.title }}
</h4> </h4>
<span class="text-xs text-gray-500 dark:text-gray-500 flex-shrink-0"> <div class="flex items-center gap-2 flex-shrink-0">
hace {{ notification.time }} <span class="text-xs text-gray-500 dark:text-gray-500">
{{ formatRelativeTime(notification.timestamp) }}
</span> </span>
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-x"
class="opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="handleRemove(notification)"
/>
</div> </div>
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)] mt-1"> </div>
<p
v-if="notification.message"
class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)] mt-1"
>
{{ notification.message }} {{ notification.message }}
</p> </p>
<!-- Badges de origen -->
<div class="flex gap-2 mt-2">
<UBadge
v-if="notification.origin === 'toast'"
color="neutral"
variant="subtle"
size="xs"
>
<template #leading>
<UIcon name="i-lucide-message-square" class="size-3" />
</template>
Toast
</UBadge>
<UBadge
v-else-if="notification.origin === 'backend'"
color="blue"
variant="subtle"
size="xs"
>
<template #leading>
<UIcon name="i-lucide-server" class="size-3" />
</template>
Sistema
</UBadge>
<UBadge
v-else-if="notification.origin === 'manual'"
color="purple"
variant="subtle"
size="xs"
>
<template #leading>
<UIcon name="i-lucide-user" class="size-3" />
</template>
Manual
</UBadge>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</UCard>
<!-- Estado vacío cuando hay filtros activos -->
<UCard
v-else-if="hasNotifications && filteredNotifications.length === 0"
class="text-center py-12"
>
<div class="flex flex-col items-center gap-4">
<div class="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<UIcon name="i-lucide-search-x" class="size-8 text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
No se encontraron notificaciones
</h3>
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
Intenta ajustar los filtros o la búsqueda
</p>
</div>
<UButton
color="neutral"
variant="soft"
@click="() => { searchQuery = ''; selectedTypes = [] }"
>
Limpiar filtros
</UButton>
</div>
</UCard>
<!-- Estado vacío cuando no hay notificaciones -->
<UCard v-else class="text-center py-12">
<div class="flex flex-col items-center gap-4">
<div class="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<UIcon name="i-lucide-bell-off" class="size-10 text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
No tienes notificaciones
</h3>
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
Cuando recibas notificaciones, aparecerán aquí
</p>
</div>
</div>
</UCard> </UCard>
</div> </div>
</UDashboardPanelContent> </UDashboardPanelContent>

View File

@@ -0,0 +1,71 @@
/**
* Endpoint para obtener notificaciones pendientes para el usuario actual
*
* Este endpoint permite al cliente hacer polling para obtener notificaciones
* generadas por el backend que aún no han sido entregadas.
*
* Casos de uso:
* - Polling periódico para obtener nuevas notificaciones
* - Sincronización inicial al cargar la aplicación
*/
// En memoria: cola de notificaciones por usuario
// Nota: En producción, esto debería estar en Redis o una BD
const notificationQueue = new Map<string, any[]>()
export default defineEventHandler(async (event) => {
try {
// Leer el usuario actual de los headers de Authentik
const headers = getHeaders(event)
const userId = headers['x-authentik-uid']
const username = headers['x-authentik-username']
// Verificar que el usuario esté autenticado
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: 'Usuario no autenticado'
})
}
// Obtener notificaciones pendientes para este usuario
const pendingNotifications = notificationQueue.get(userId) || []
// Limpiar la cola después de entregarlas
notificationQueue.delete(userId)
return {
success: true,
notifications: pendingNotifications,
count: pendingNotifications.length,
user: {
uid: userId,
username
}
}
} catch (error: any) {
console.error('Error al obtener notificaciones:', error)
// Si ya es un error de H3, relanzarlo
if (error.statusCode) {
throw error
}
// Error genérico
throw createError({
statusCode: 500,
statusMessage: 'Error al obtener notificaciones'
})
}
})
/**
* Función helper para agregar una notificación a la cola de un usuario
* Esta función puede ser llamada desde otros endpoints del servidor
*/
export function queueNotification(userId: string, notification: any) {
if (!notificationQueue.has(userId)) {
notificationQueue.set(userId, [])
}
notificationQueue.get(userId)!.push(notification)
}

View File

@@ -0,0 +1,95 @@
/**
* Endpoint para enviar notificaciones a usuarios específicos desde el backend
*
* Esta API permite crear notificaciones que se entregarán directamente
* al cliente para ser guardadas en su localStorage.
*
* Casos de uso:
* - Notificaciones del sistema
* - Alertas generadas por procesos del backend
* - Notificaciones administrativas
*/
export default defineEventHandler(async (event) => {
try {
// Leer el body de la petición
const body = await readBody(event)
// Validar campos requeridos
if (!body.targetUserId || !body.title) {
throw createError({
statusCode: 400,
statusMessage: 'Faltan campos requeridos: targetUserId y title'
})
}
// Validar tipo de notificación
const validTypes = ['info', 'warning', 'success', 'error']
const type = body.type || 'info'
if (!validTypes.includes(type)) {
throw createError({
statusCode: 400,
statusMessage: `Tipo inválido. Debe ser uno de: ${validTypes.join(', ')}`
})
}
// Leer el usuario actual de los headers de Authentik
const headers = getHeaders(event)
const currentUserId = headers['x-authentik-uid']
const currentUsername = headers['x-authentik-username']
// Verificar que el usuario esté autenticado
if (!currentUserId) {
throw createError({
statusCode: 401,
statusMessage: 'Usuario no autenticado'
})
}
// Opcional: verificar permisos (por ejemplo, solo admins pueden enviar notificaciones)
// const groups = headers['x-authentik-groups']?.split('|') || []
// if (!groups.includes('admins')) {
// throw createError({
// statusCode: 403,
// statusMessage: 'No tienes permisos para enviar notificaciones'
// })
// }
// Construir la notificación
const notification = {
targetUserId: body.targetUserId,
type: type as 'info' | 'warning' | 'success' | 'error',
title: body.title,
message: body.message || '',
origin: 'backend' as const,
metadata: {
sentBy: currentUsername,
sentAt: new Date().toISOString(),
...body.metadata
}
}
// Nota: Como usamos localStorage en el cliente, no guardamos en BD
// En su lugar, retornamos la notificación para que el cliente la guarde
// El cliente debe hacer polling o usar SSE/WebSocket para obtener notificaciones
return {
success: true,
notification,
message: 'Notificación creada correctamente'
}
} catch (error: any) {
console.error('Error al enviar notificación:', error)
// Si ya es un error de H3, relanzarlo
if (error.statusCode) {
throw error
}
// Error genérico
throw createError({
statusCode: 500,
statusMessage: 'Error al procesar la notificación'
})
}
})