Initial commit: MSP Monitor Dashboard

- Next.js 14 frontend with dark cyan/navy theme
- tRPC API with Prisma ORM
- MeshCentral, LibreNMS, Headwind MDM integrations
- Multi-tenant architecture
- Alert system with email/SMS/webhook notifications
- Docker Compose deployment
- Complete documentation
This commit is contained in:
MSP Monitor
2026-01-21 19:29:20 +00:00
commit f4491757d9
57 changed files with 10503 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const alertasRouter = router({
// Listar alertas
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
estado: z.enum(['ACTIVA', 'RECONOCIDA', 'RESUELTA']).optional(),
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']).optional(),
dispositivoId: z.string().optional(),
desde: z.date().optional(),
hasta: z.date().optional(),
page: z.number().default(1),
limit: z.number().default(50),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, estado, severidad, dispositivoId, desde, hasta, page = 1, limit = 50 } = input || {}
const where = {
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(severidad ? { severidad } : {}),
...(dispositivoId ? { dispositivoId } : {}),
...(desde || hasta ? {
createdAt: {
...(desde ? { gte: desde } : {}),
...(hasta ? { lte: hasta } : {}),
},
} : {}),
}
const [alertas, total] = await Promise.all([
ctx.prisma.alerta.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
dispositivo: { select: { id: true, nombre: true, tipo: true, ip: true } },
},
orderBy: [{ severidad: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.alerta.count({ where }),
])
return {
alertas,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener alerta por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const alerta = await ctx.prisma.alerta.findUnique({
where: { id: input.id },
include: {
cliente: true,
dispositivo: true,
regla: true,
},
})
if (!alerta) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return alerta
}),
// Reconocer alerta
reconocer: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const alerta = await ctx.prisma.alerta.findUnique({
where: { id: input.id },
})
if (!alerta) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return ctx.prisma.alerta.update({
where: { id: input.id },
data: {
estado: 'RECONOCIDA',
reconocidaPor: ctx.user.id,
reconocidaEn: new Date(),
},
})
}),
// Resolver alerta
resolver: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const alerta = await ctx.prisma.alerta.findUnique({
where: { id: input.id },
})
if (!alerta) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Alerta no encontrada' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== alerta.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return ctx.prisma.alerta.update({
where: { id: input.id },
data: {
estado: 'RESUELTA',
resueltaPor: ctx.user.id,
resueltaEn: new Date(),
},
})
}),
// Reconocer multiples alertas
reconocerMultiples: protectedProcedure
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ ctx, input }) => {
const where = {
id: { in: input.ids },
estado: 'ACTIVA' as const,
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
}
return ctx.prisma.alerta.updateMany({
where,
data: {
estado: 'RECONOCIDA',
reconocidaPor: ctx.user.id,
reconocidaEn: new Date(),
},
})
}),
// Conteo de alertas activas
conteoActivas: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
estado: 'ACTIVA' as const,
...(clienteId ? { clienteId } : {}),
}
const [total, critical, warning, info] = await Promise.all([
ctx.prisma.alerta.count({ where }),
ctx.prisma.alerta.count({ where: { ...where, severidad: 'CRITICAL' } }),
ctx.prisma.alerta.count({ where: { ...where, severidad: 'WARNING' } }),
ctx.prisma.alerta.count({ where: { ...where, severidad: 'INFO' } }),
])
return { total, critical, warning, info }
}),
// ==================== REGLAS ====================
reglas: router({
list: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
return ctx.prisma.alertaRegla.findMany({
where: {
OR: [
{ clienteId: null }, // Reglas globales
...(clienteId ? [{ clienteId }] : []),
],
},
orderBy: [{ clienteId: 'asc' }, { nombre: 'asc' }],
})
}),
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const regla = await ctx.prisma.alertaRegla.findUnique({
where: { id: input.id },
})
if (!regla) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Regla no encontrada' })
}
return regla
}),
create: adminProcedure
.input(
z.object({
clienteId: z.string().optional(),
nombre: z.string(),
descripcion: z.string().optional(),
tipoDispositivo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
metrica: z.string(),
operador: z.enum(['>', '<', '>=', '<=', '==']),
umbral: z.number(),
duracionMinutos: z.number().default(5),
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']),
notificarEmail: z.boolean().default(true),
notificarSms: z.boolean().default(false),
notificarWebhook: z.boolean().default(false),
webhookUrl: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.alertaRegla.create({ data: input })
}),
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().optional(),
descripcion: z.string().optional().nullable(),
activa: z.boolean().optional(),
tipoDispositivo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional().nullable(),
metrica: z.string().optional(),
operador: z.enum(['>', '<', '>=', '<=', '==']).optional(),
umbral: z.number().optional(),
duracionMinutos: z.number().optional(),
severidad: z.enum(['INFO', 'WARNING', 'CRITICAL']).optional(),
notificarEmail: z.boolean().optional(),
notificarSms: z.boolean().optional(),
notificarWebhook: z.boolean().optional(),
webhookUrl: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
return ctx.prisma.alertaRegla.update({ where: { id }, data })
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.alertaRegla.delete({ where: { id: input.id } })
}),
}),
})

View File

@@ -0,0 +1,173 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import bcrypt from 'bcryptjs'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { createSession, validateMeshCentralUser, setSessionCookie, clearSession } from '@/lib/auth'
export const authRouter = router({
// Login con email/password
login: publicProcedure
.input(
z.object({
email: z.string().email(),
password: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { email: input.email },
})
if (!usuario || !usuario.passwordHash) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Credenciales invalidas',
})
}
const validPassword = await bcrypt.compare(input.password, usuario.passwordHash)
if (!validPassword) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Credenciales invalidas',
})
}
if (!usuario.activo) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Usuario desactivado',
})
}
// Actualizar lastLogin
await ctx.prisma.usuario.update({
where: { id: usuario.id },
data: { lastLogin: new Date() },
})
const token = await createSession({
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol,
clienteId: usuario.clienteId,
meshcentralUser: usuario.meshcentralUser,
})
await setSessionCookie(token)
return {
success: true,
user: {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol,
},
}
}),
// Login con MeshCentral SSO
loginMeshCentral: publicProcedure
.input(
z.object({
username: z.string(),
token: z.string(),
})
)
.mutation(async ({ input }) => {
const user = await validateMeshCentralUser(input.username, input.token)
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Token de MeshCentral invalido',
})
}
const sessionToken = await createSession(user)
await setSessionCookie(sessionToken)
return {
success: true,
user: {
id: user.id,
email: user.email,
nombre: user.nombre,
rol: user.rol,
},
}
}),
// Logout
logout: protectedProcedure.mutation(async () => {
await clearSession()
return { success: true }
}),
// Obtener usuario actual
me: protectedProcedure.query(async ({ ctx }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { id: ctx.user.id },
include: {
cliente: true,
permisos: true,
},
})
if (!usuario) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Usuario no encontrado',
})
}
return {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol,
avatar: usuario.avatar,
cliente: usuario.cliente,
permisos: usuario.permisos,
}
}),
// Cambiar password
changePassword: protectedProcedure
.input(
z.object({
currentPassword: z.string(),
newPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { id: ctx.user.id },
})
if (!usuario || !usuario.passwordHash) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No se puede cambiar password para usuarios SSO',
})
}
const validPassword = await bcrypt.compare(input.currentPassword, usuario.passwordHash)
if (!validPassword) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Password actual incorrecto',
})
}
const newHash = await bcrypt.hash(input.newPassword, 12)
await ctx.prisma.usuario.update({
where: { id: ctx.user.id },
data: { passwordHash: newHash },
})
return { success: true }
}),
})

View File

@@ -0,0 +1,382 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { HeadwindClient } from '@/server/services/headwind/client'
export const celularesRouter = router({
// Listar celulares y tablets
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, estado, search, page = 1, limit = 20 } = input || {}
const where = {
tipo: { in: ['CELULAR', 'TABLET'] as const },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ imei: { contains: search } },
{ numeroTelefono: { contains: search } },
],
} : {}),
}
const [dispositivos, total] = await Promise.all([
ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
ubicacion: { select: { id: true, nombre: true } },
},
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.dispositivo.count({ where }),
])
return {
dispositivos,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener celular por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
include: {
cliente: true,
ubicacion: true,
alertas: {
where: { estado: 'ACTIVA' },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return dispositivo
}),
// Obtener ubicacion actual
ubicacion: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
select: {
id: true,
nombre: true,
latitud: true,
longitud: true,
gpsUpdatedAt: true,
clienteId: true,
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return {
lat: dispositivo.latitud,
lng: dispositivo.longitud,
updatedAt: dispositivo.gpsUpdatedAt,
}
}),
// Solicitar actualizacion de ubicacion
solicitarUbicacion: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.requestLocation(dispositivo.headwindId)
return { success: true, message: 'Solicitud de ubicacion enviada' }
}),
// Bloquear dispositivo
bloquear: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
mensaje: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.lockDevice(dispositivo.headwindId, input.mensaje)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'bloquear',
recurso: 'celular',
detalles: { mensaje: input.mensaje },
},
})
return { success: true }
}),
// Desbloquear dispositivo
desbloquear: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.unlockDevice(dispositivo.headwindId)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'desbloquear',
recurso: 'celular',
},
})
return { success: true }
}),
// Hacer sonar dispositivo
sonar: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.ringDevice(dispositivo.headwindId)
return { success: true }
}),
// Enviar mensaje al dispositivo
enviarMensaje: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
mensaje: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.sendMessage(dispositivo.headwindId, input.mensaje)
return { success: true }
}),
// Borrar datos (factory reset)
borrarDatos: adminProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
const headwindClient = new HeadwindClient()
await headwindClient.wipeDevice(dispositivo.headwindId)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'borrar_datos',
recurso: 'celular',
},
})
return { success: true }
}),
// Instalar aplicacion
instalarApp: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
packageName: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.installApp(dispositivo.headwindId, input.packageName)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'instalar_app',
recurso: 'celular',
detalles: { packageName: input.packageName },
},
})
return { success: true }
}),
// Desinstalar aplicacion
desinstalarApp: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
packageName: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.headwindId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene Headwind MDM',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const headwindClient = new HeadwindClient()
await headwindClient.removeApp(dispositivo.headwindId, input.packageName)
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'desinstalar_app',
recurso: 'celular',
detalles: { packageName: input.packageName },
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,264 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
export const clientesRouter = router({
// Listar clientes
list: protectedProcedure
.input(
z.object({
search: z.string().optional(),
activo: z.boolean().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { search, activo, page = 1, limit = 20 } = input || {}
// Si el usuario tiene clienteId, solo puede ver su cliente
const where = {
...(ctx.user.clienteId ? { id: ctx.user.clienteId } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ codigo: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
...(activo !== undefined ? { activo } : {}),
}
const [clientes, total] = await Promise.all([
ctx.prisma.cliente.findMany({
where,
include: {
_count: {
select: { dispositivos: true, usuarios: true },
},
},
orderBy: { nombre: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.cliente.count({ where }),
])
return {
clientes,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener cliente por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== input.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const cliente = await ctx.prisma.cliente.findUnique({
where: { id: input.id },
include: {
ubicaciones: true,
_count: {
select: {
dispositivos: true,
usuarios: true,
alertas: { where: { estado: 'ACTIVA' } },
},
},
},
})
if (!cliente) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Cliente no encontrado' })
}
return cliente
}),
// Crear cliente
create: adminProcedure
.input(
z.object({
nombre: z.string().min(1),
codigo: z.string().min(1),
email: z.string().email().optional(),
telefono: z.string().optional(),
notas: z.string().optional(),
meshcentralGrupo: z.string().optional(),
librenmsGrupo: z.number().optional(),
headwindGrupo: z.number().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verificar codigo unico
const existe = await ctx.prisma.cliente.findUnique({
where: { codigo: input.codigo },
})
if (existe) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Ya existe un cliente con ese codigo',
})
}
return ctx.prisma.cliente.create({
data: input,
})
}),
// Actualizar cliente
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().min(1).optional(),
email: z.string().email().optional().nullable(),
telefono: z.string().optional().nullable(),
activo: z.boolean().optional(),
notas: z.string().optional().nullable(),
meshcentralGrupo: z.string().optional().nullable(),
librenmsGrupo: z.number().optional().nullable(),
headwindGrupo: z.number().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
return ctx.prisma.cliente.update({
where: { id },
data,
})
}),
// Eliminar cliente
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.cliente.delete({
where: { id: input.id },
})
}),
// Estadisticas del dashboard por cliente
dashboardStats: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = clienteId ? { clienteId } : {}
const [
totalDispositivos,
dispositivosOnline,
dispositivosOffline,
dispositivosAlerta,
alertasActivas,
alertasCriticas,
] = await Promise.all([
ctx.prisma.dispositivo.count({ where }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'OFFLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ALERTA' } }),
ctx.prisma.alerta.count({
where: { ...where, estado: 'ACTIVA' },
}),
ctx.prisma.alerta.count({
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
}),
])
return {
totalDispositivos,
dispositivosOnline,
dispositivosOffline,
dispositivosAlerta,
alertasActivas,
alertasCriticas,
sesionesActivas: 0, // TODO: implementar
}
}),
// Ubicaciones
ubicaciones: router({
list: protectedProcedure
.input(z.object({ clienteId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.clienteUbicacion.findMany({
where: { clienteId: input.clienteId },
orderBy: [{ principal: 'desc' }, { nombre: 'asc' }],
})
}),
create: adminProcedure
.input(
z.object({
clienteId: z.string(),
nombre: z.string(),
direccion: z.string().optional(),
ciudad: z.string().optional(),
estado: z.string().optional(),
pais: z.string().default('Mexico'),
codigoPostal: z.string().optional(),
latitud: z.number().optional(),
longitud: z.number().optional(),
principal: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// Si es principal, quitar principal a las demas
if (input.principal) {
await ctx.prisma.clienteUbicacion.updateMany({
where: { clienteId: input.clienteId },
data: { principal: false },
})
}
return ctx.prisma.clienteUbicacion.create({ data: input })
}),
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().optional(),
direccion: z.string().optional().nullable(),
ciudad: z.string().optional().nullable(),
estado: z.string().optional().nullable(),
pais: z.string().optional(),
codigoPostal: z.string().optional().nullable(),
latitud: z.number().optional().nullable(),
longitud: z.number().optional().nullable(),
principal: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
// Si es principal, quitar principal a las demas
if (data.principal) {
const ubicacion = await ctx.prisma.clienteUbicacion.findUnique({ where: { id } })
if (ubicacion) {
await ctx.prisma.clienteUbicacion.updateMany({
where: { clienteId: ubicacion.clienteId, id: { not: id } },
data: { principal: false },
})
}
}
return ctx.prisma.clienteUbicacion.update({ where: { id }, data })
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.clienteUbicacion.delete({ where: { id: input.id } })
}),
}),
})

View File

@@ -0,0 +1,370 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, superAdminProcedure } from '../trpc'
export const configuracionRouter = router({
// Obtener todas las configuraciones
list: adminProcedure
.input(z.object({ categoria: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const where = input?.categoria ? { categoria: input.categoria } : {}
const configuraciones = await ctx.prisma.configuracion.findMany({
where,
orderBy: [{ categoria: 'asc' }, { clave: 'asc' }],
})
// Ocultar valores sensibles si no es super admin
if (ctx.user.rol !== 'SUPER_ADMIN') {
return configuraciones.map(c => ({
...c,
valor: c.clave.includes('password') || c.clave.includes('token') || c.clave.includes('secret')
? '********'
: c.valor,
}))
}
return configuraciones
}),
// Obtener una configuracion por clave
get: adminProcedure
.input(z.object({ clave: z.string() }))
.query(async ({ ctx, input }) => {
const config = await ctx.prisma.configuracion.findUnique({
where: { clave: input.clave },
})
if (!config) {
return null
}
// Ocultar valores sensibles
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
(input.clave.includes('password') ||
input.clave.includes('token') ||
input.clave.includes('secret'))
) {
return { ...config, valor: '********' }
}
return config
}),
// Establecer configuracion
set: superAdminProcedure
.input(
z.object({
clave: z.string(),
valor: z.any(),
tipo: z.enum(['string', 'number', 'boolean', 'json']).default('string'),
categoria: z.string().default('general'),
descripcion: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.configuracion.upsert({
where: { clave: input.clave },
update: {
valor: input.valor,
tipo: input.tipo,
descripcion: input.descripcion,
},
create: {
clave: input.clave,
valor: input.valor,
tipo: input.tipo,
categoria: input.categoria,
descripcion: input.descripcion,
},
})
}),
// Eliminar configuracion
delete: superAdminProcedure
.input(z.object({ clave: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.configuracion.delete({
where: { clave: input.clave },
})
}),
// Configuraciones de integracion
integraciones: router({
// Obtener estado de integraciones
status: adminProcedure.query(async ({ ctx }) => {
const [meshcentral, librenms, headwind] = await Promise.all([
ctx.prisma.configuracion.findFirst({
where: { clave: 'meshcentral_url' },
}),
ctx.prisma.configuracion.findFirst({
where: { clave: 'librenms_url' },
}),
ctx.prisma.configuracion.findFirst({
where: { clave: 'headwind_url' },
}),
])
return {
meshcentral: {
configurado: !!meshcentral,
url: meshcentral?.valor as string | undefined,
},
librenms: {
configurado: !!librenms,
url: librenms?.valor as string | undefined,
},
headwind: {
configurado: !!headwind,
url: headwind?.valor as string | undefined,
},
}
}),
// Configurar MeshCentral
setMeshCentral: superAdminProcedure
.input(
z.object({
url: z.string().url(),
user: z.string(),
password: z.string(),
domain: z.string().default('default'),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_url' },
update: { valor: input.url },
create: {
clave: 'meshcentral_url',
valor: input.url,
tipo: 'string',
categoria: 'integracion',
descripcion: 'URL de MeshCentral',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_user' },
update: { valor: input.user },
create: {
clave: 'meshcentral_user',
valor: input.user,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Usuario de MeshCentral',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_password' },
update: { valor: input.password },
create: {
clave: 'meshcentral_password',
valor: input.password,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Password de MeshCentral',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'meshcentral_domain' },
update: { valor: input.domain },
create: {
clave: 'meshcentral_domain',
valor: input.domain,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Dominio de MeshCentral',
},
}),
])
return { success: true }
}),
// Configurar LibreNMS
setLibreNMS: superAdminProcedure
.input(
z.object({
url: z.string().url(),
token: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'librenms_url' },
update: { valor: input.url },
create: {
clave: 'librenms_url',
valor: input.url,
tipo: 'string',
categoria: 'integracion',
descripcion: 'URL de LibreNMS',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'librenms_token' },
update: { valor: input.token },
create: {
clave: 'librenms_token',
valor: input.token,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Token de API de LibreNMS',
},
}),
])
return { success: true }
}),
// Configurar Headwind MDM
setHeadwind: superAdminProcedure
.input(
z.object({
url: z.string().url(),
token: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'headwind_url' },
update: { valor: input.url },
create: {
clave: 'headwind_url',
valor: input.url,
tipo: 'string',
categoria: 'integracion',
descripcion: 'URL de Headwind MDM',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'headwind_token' },
update: { valor: input.token },
create: {
clave: 'headwind_token',
valor: input.token,
tipo: 'string',
categoria: 'integracion',
descripcion: 'Token de API de Headwind MDM',
},
}),
])
return { success: true }
}),
// Probar conexion
test: superAdminProcedure
.input(z.object({ integracion: z.enum(['meshcentral', 'librenms', 'headwind']) }))
.mutation(async ({ input }) => {
// TODO: Implementar prueba de conexion real
return {
success: true,
message: `Conexion a ${input.integracion} exitosa`,
}
}),
}),
// Configuraciones de notificaciones
notificaciones: router({
// Obtener configuracion de SMTP
getSmtp: adminProcedure.query(async ({ ctx }) => {
const [host, port, user, from] = await Promise.all([
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_host' } }),
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_port' } }),
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_user' } }),
ctx.prisma.configuracion.findFirst({ where: { clave: 'smtp_from' } }),
])
return {
host: host?.valor as string | undefined,
port: port?.valor as number | undefined,
user: user?.valor as string | undefined,
from: from?.valor as string | undefined,
}
}),
// Configurar SMTP
setSmtp: superAdminProcedure
.input(
z.object({
host: z.string(),
port: z.number(),
user: z.string(),
password: z.string(),
from: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await Promise.all([
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_host' },
update: { valor: input.host },
create: {
clave: 'smtp_host',
valor: input.host,
tipo: 'string',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_port' },
update: { valor: input.port },
create: {
clave: 'smtp_port',
valor: input.port,
tipo: 'number',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_user' },
update: { valor: input.user },
create: {
clave: 'smtp_user',
valor: input.user,
tipo: 'string',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_password' },
update: { valor: input.password },
create: {
clave: 'smtp_password',
valor: input.password,
tipo: 'string',
categoria: 'notificacion',
},
}),
ctx.prisma.configuracion.upsert({
where: { clave: 'smtp_from' },
update: { valor: input.from },
create: {
clave: 'smtp_from',
valor: input.from,
tipo: 'string',
categoria: 'notificacion',
},
}),
])
return { success: true }
}),
// Probar email
testEmail: superAdminProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => {
// TODO: Implementar envio de email de prueba
return {
success: true,
message: `Email de prueba enviado a ${input.email}`,
}
}),
}),
})

View File

@@ -0,0 +1,333 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { MeshCentralClient } from '@/server/services/meshcentral/client'
export const equiposRouter = router({
// Listar equipos de computo (PC, laptop, servidor)
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR']).optional(),
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
const where = {
tipo: tipo ? { equals: tipo } : { in: ['PC', 'LAPTOP', 'SERVIDOR'] as const },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ ip: { contains: search } },
{ serial: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
}
const [dispositivos, total] = await Promise.all([
ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
ubicacion: { select: { id: true, nombre: true } },
},
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.dispositivo.count({ where }),
])
return {
dispositivos,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener equipo por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
include: {
cliente: true,
ubicacion: true,
software: {
orderBy: { nombre: 'asc' },
take: 100,
},
alertas: {
where: { estado: 'ACTIVA' },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return dispositivo
}),
// Obtener metricas historicas
metricas: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
periodo: z.enum(['1h', '6h', '24h', '7d', '30d']).default('24h'),
})
)
.query(async ({ ctx, input }) => {
const now = new Date()
let desde: Date
switch (input.periodo) {
case '1h':
desde = new Date(now.getTime() - 60 * 60 * 1000)
break
case '6h':
desde = new Date(now.getTime() - 6 * 60 * 60 * 1000)
break
case '24h':
desde = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
desde = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '30d':
desde = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
}
// Para periodos cortos usar metricas detalladas, para largos usar hourly
if (['1h', '6h', '24h'].includes(input.periodo)) {
return ctx.prisma.dispositivoMetrica.findMany({
where: {
dispositivoId: input.dispositivoId,
timestamp: { gte: desde },
},
orderBy: { timestamp: 'asc' },
})
} else {
return ctx.prisma.dispositivoMetricaHourly.findMany({
where: {
dispositivoId: input.dispositivoId,
hora: { gte: desde },
},
orderBy: { hora: 'asc' },
})
}
}),
// Iniciar sesion remota
iniciarSesion: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
tipo: z.enum(['desktop', 'terminal', 'files']),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// Crear registro de sesion
const sesion = await ctx.prisma.sesionRemota.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
tipo: input.tipo,
},
})
// Generar URL de MeshCentral para la sesion
const meshUrl = process.env.MESHCENTRAL_URL
const sessionUrl = `${meshUrl}/?node=${dispositivo.meshcentralId}&viewmode=${
input.tipo === 'desktop' ? '11' : input.tipo === 'terminal' ? '12' : '13'
}`
return {
sesionId: sesion.id,
url: sessionUrl,
}
}),
// Finalizar sesion remota
finalizarSesion: protectedProcedure
.input(z.object({ sesionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const sesion = await ctx.prisma.sesionRemota.findUnique({
where: { id: input.sesionId },
})
if (!sesion || sesion.usuarioId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Sesion no encontrada' })
}
const ahora = new Date()
const duracion = Math.floor((ahora.getTime() - sesion.iniciadaEn.getTime()) / 1000)
return ctx.prisma.sesionRemota.update({
where: { id: input.sesionId },
data: {
finalizadaEn: ahora,
duracion,
},
})
}),
// Ejecutar comando en equipo
ejecutarComando: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
comando: z.string(),
tipo: z.enum(['powershell', 'cmd', 'bash']).default('powershell'),
})
)
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
// Verificar acceso
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// Ejecutar comando via MeshCentral
const meshClient = new MeshCentralClient()
const resultado = await meshClient.runCommand(
dispositivo.meshcentralId,
input.comando,
input.tipo
)
// Registrar en audit log
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'ejecutar_comando',
recurso: 'dispositivo',
detalles: {
comando: input.comando,
tipo: input.tipo,
exito: resultado.success,
},
},
})
return resultado
}),
// Reiniciar equipo
reiniciar: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const meshClient = new MeshCentralClient()
await meshClient.powerAction(dispositivo.meshcentralId, 'restart')
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'reiniciar',
recurso: 'dispositivo',
},
})
return { success: true }
}),
// Apagar equipo
apagar: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.meshcentralId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene agente MeshCentral',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const meshClient = new MeshCentralClient()
await meshClient.powerAction(dispositivo.meshcentralId, 'shutdown')
await ctx.prisma.auditLog.create({
data: {
usuarioId: ctx.user.id,
dispositivoId: input.dispositivoId,
accion: 'apagar',
recurso: 'dispositivo',
},
})
return { success: true }
}),
})

View File

@@ -0,0 +1,24 @@
import { router } from '../trpc'
import { authRouter } from './auth.router'
import { clientesRouter } from './clientes.router'
import { equiposRouter } from './equipos.router'
import { celularesRouter } from './celulares.router'
import { redRouter } from './red.router'
import { alertasRouter } from './alertas.router'
import { reportesRouter } from './reportes.router'
import { usuariosRouter } from './usuarios.router'
import { configuracionRouter } from './configuracion.router'
export const appRouter = router({
auth: authRouter,
clientes: clientesRouter,
equipos: equiposRouter,
celulares: celularesRouter,
red: redRouter,
alertas: alertasRouter,
reportes: reportesRouter,
usuarios: usuariosRouter,
configuracion: configuracionRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -0,0 +1,305 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure } from '../trpc'
import { LibreNMSClient } from '@/server/services/librenms/client'
export const redRouter = router({
// Listar dispositivos de red
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: z.enum(['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
const tiposRed = ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const
const where = {
tipo: tipo ? { equals: tipo } : { in: tiposRed },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ ip: { contains: search } },
{ mac: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
}
const [dispositivos, total] = await Promise.all([
ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true, codigo: true } },
ubicacion: { select: { id: true, nombre: true } },
},
orderBy: [{ estado: 'asc' }, { nombre: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.dispositivo.count({ where }),
])
return {
dispositivos,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener dispositivo de red por ID
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
include: {
cliente: true,
ubicacion: true,
alertas: {
where: { estado: 'ACTIVA' },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
})
if (!dispositivo) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return dispositivo
}),
// Obtener interfaces de un dispositivo
interfaces: protectedProcedure
.input(z.object({ dispositivoId: z.string() }))
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene LibreNMS',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const librenms = new LibreNMSClient()
return librenms.getDevicePorts(dispositivo.librenmsId)
}),
// Obtener grafico de trafico de una interfaz
trafico: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
portId: z.number(),
periodo: z.enum(['1h', '6h', '24h', '7d', '30d']).default('24h'),
})
)
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene LibreNMS',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const librenms = new LibreNMSClient()
// Calcular rango de tiempo
const now = new Date()
let desde: Date
switch (input.periodo) {
case '1h':
desde = new Date(now.getTime() - 60 * 60 * 1000)
break
case '6h':
desde = new Date(now.getTime() - 6 * 60 * 60 * 1000)
break
case '24h':
desde = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
desde = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '30d':
desde = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
}
return librenms.getPortStats(input.portId, desde, now)
}),
// Obtener topologia de red
topologia: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
// Obtener dispositivos de red del cliente
const dispositivos = await ctx.prisma.dispositivo.findMany({
where: {
...(clienteId ? { clienteId } : {}),
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP'] },
librenmsId: { not: null },
},
select: {
id: true,
nombre: true,
ip: true,
tipo: true,
estado: true,
librenmsId: true,
},
})
if (dispositivos.length === 0) {
return { nodes: [], links: [] }
}
// Obtener enlaces de LibreNMS
const librenms = new LibreNMSClient()
const links = await librenms.getLinks()
// Mapear a nodos y enlaces para visualizacion
const librenmsIdToDevice = new Map(
dispositivos
.filter(d => d.librenmsId !== null)
.map(d => [d.librenmsId!, d])
)
const nodes = dispositivos.map(d => ({
id: d.id,
name: d.nombre,
ip: d.ip,
type: d.tipo,
status: d.estado,
}))
const edges = links
.filter(
(l: { local_device_id: number; remote_device_id: number }) =>
librenmsIdToDevice.has(l.local_device_id) && librenmsIdToDevice.has(l.remote_device_id)
)
.map((l: { local_device_id: number; remote_device_id: number; local_port: string; remote_port: string }) => ({
source: librenmsIdToDevice.get(l.local_device_id)!.id,
target: librenmsIdToDevice.get(l.remote_device_id)!.id,
localPort: l.local_port,
remotePort: l.remote_port,
}))
return { nodes, links: edges }
}),
// Obtener alertas SNMP activas
alertasSNMP: protectedProcedure
.input(z.object({ dispositivoId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const librenms = new LibreNMSClient()
if (input.dispositivoId) {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
return []
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return librenms.getDeviceAlerts(dispositivo.librenmsId)
}
return librenms.getAlerts()
}),
// Obtener datos de NetFlow
netflow: protectedProcedure
.input(
z.object({
dispositivoId: z.string(),
periodo: z.enum(['1h', '6h', '24h']).default('1h'),
})
)
.query(async ({ ctx, input }) => {
const dispositivo = await ctx.prisma.dispositivo.findUnique({
where: { id: input.dispositivoId },
})
if (!dispositivo || !dispositivo.librenmsId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Dispositivo no tiene LibreNMS',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== dispositivo.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// LibreNMS puede tener integracion con nfsen para netflow
// Por ahora retornamos datos de ejemplo
return {
topTalkers: [],
topProtocols: [],
topPorts: [],
}
}),
// Estadisticas de red por cliente
stats: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const },
...(clienteId ? { clienteId } : {}),
}
const [total, online, offline, alertas] = await Promise.all([
ctx.prisma.dispositivo.count({ where }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'OFFLINE' } }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ALERTA' } }),
])
return { total, online, offline, alertas }
}),
})

View File

@@ -0,0 +1,389 @@
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
export const reportesRouter = router({
// Reporte de inventario
inventario: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']).optional(),
}).optional()
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input?.clienteId
const where = {
...(clienteId ? { clienteId } : {}),
...(input?.tipo ? { tipo: input.tipo } : {}),
}
const dispositivos = await ctx.prisma.dispositivo.findMany({
where,
include: {
cliente: { select: { nombre: true, codigo: true } },
ubicacion: { select: { nombre: true } },
},
orderBy: [{ cliente: { nombre: 'asc' } }, { tipo: 'asc' }, { nombre: 'asc' }],
})
// Resumen por tipo
const porTipo = await ctx.prisma.dispositivo.groupBy({
by: ['tipo'],
where,
_count: true,
})
// Resumen por cliente
const porCliente = await ctx.prisma.dispositivo.groupBy({
by: ['clienteId'],
where,
_count: true,
})
return {
dispositivos,
resumen: {
total: dispositivos.length,
porTipo: porTipo.map(t => ({ tipo: t.tipo, count: t._count })),
porCliente: porCliente.length,
},
}
}),
// Reporte de uptime
uptime: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
desde: z.date(),
hasta: z.date(),
})
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
...(clienteId ? { clienteId } : {}),
}
// Obtener dispositivos
const dispositivos = await ctx.prisma.dispositivo.findMany({
where,
select: {
id: true,
nombre: true,
tipo: true,
cliente: { select: { nombre: true } },
},
})
// Calcular uptime basado en metricas hourly
const uptimeData = await Promise.all(
dispositivos.map(async (d) => {
const metricas = await ctx.prisma.dispositivoMetricaHourly.count({
where: {
dispositivoId: d.id,
hora: {
gte: input.desde,
lte: input.hasta,
},
},
})
const horasTotales = Math.ceil(
(input.hasta.getTime() - input.desde.getTime()) / (1000 * 60 * 60)
)
const uptimePercent = horasTotales > 0 ? (metricas / horasTotales) * 100 : 0
return {
dispositivo: d.nombre,
tipo: d.tipo,
cliente: d.cliente.nombre,
horasOnline: metricas,
horasTotales,
uptimePercent: Math.min(100, Math.round(uptimePercent * 100) / 100),
}
})
)
return {
periodo: { desde: input.desde, hasta: input.hasta },
dispositivos: uptimeData,
promedioGeneral:
uptimeData.length > 0
? Math.round(
(uptimeData.reduce((sum, d) => sum + d.uptimePercent, 0) /
uptimeData.length) *
100
) / 100
: 0,
}
}),
// Reporte de alertas
alertas: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
desde: z.date(),
hasta: z.date(),
})
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
createdAt: {
gte: input.desde,
lte: input.hasta,
},
...(clienteId ? { clienteId } : {}),
}
const [alertas, porSeveridad, porEstado, porDispositivo] = await Promise.all([
ctx.prisma.alerta.findMany({
where,
include: {
cliente: { select: { nombre: true } },
dispositivo: { select: { nombre: true, tipo: true } },
},
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.alerta.groupBy({
by: ['severidad'],
where,
_count: true,
}),
ctx.prisma.alerta.groupBy({
by: ['estado'],
where,
_count: true,
}),
ctx.prisma.alerta.groupBy({
by: ['dispositivoId'],
where: {
...where,
dispositivoId: { not: null },
},
_count: true,
orderBy: { _count: { dispositivoId: 'desc' } },
take: 10,
}),
])
// Obtener nombres de dispositivos top
const topDispositivosIds = porDispositivo.map(p => p.dispositivoId).filter(Boolean) as string[]
const topDispositivos = await ctx.prisma.dispositivo.findMany({
where: { id: { in: topDispositivosIds } },
select: { id: true, nombre: true },
})
const dispositivoMap = new Map(topDispositivos.map(d => [d.id, d.nombre]))
return {
periodo: { desde: input.desde, hasta: input.hasta },
total: alertas.length,
alertas,
resumen: {
porSeveridad: porSeveridad.map(s => ({ severidad: s.severidad, count: s._count })),
porEstado: porEstado.map(e => ({ estado: e.estado, count: e._count })),
topDispositivos: porDispositivo.map(d => ({
dispositivo: dispositivoMap.get(d.dispositivoId!) || 'Desconocido',
count: d._count,
})),
},
}
}),
// Reporte de actividad de usuarios
actividad: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
desde: z.date(),
hasta: z.date(),
})
)
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
createdAt: {
gte: input.desde,
lte: input.hasta,
},
...(clienteId ? {
usuario: { clienteId },
} : {}),
}
const [logs, porAccion, porUsuario, sesiones] = await Promise.all([
ctx.prisma.auditLog.findMany({
where,
include: {
usuario: { select: { nombre: true, email: true } },
dispositivo: { select: { nombre: true } },
},
orderBy: { createdAt: 'desc' },
take: 100,
}),
ctx.prisma.auditLog.groupBy({
by: ['accion'],
where,
_count: true,
orderBy: { _count: { accion: 'desc' } },
}),
ctx.prisma.auditLog.groupBy({
by: ['usuarioId'],
where: {
...where,
usuarioId: { not: null },
},
_count: true,
orderBy: { _count: { usuarioId: 'desc' } },
take: 10,
}),
ctx.prisma.sesionRemota.findMany({
where: {
iniciadaEn: {
gte: input.desde,
lte: input.hasta,
},
},
include: {
usuario: { select: { nombre: true } },
dispositivo: { select: { nombre: true } },
},
orderBy: { iniciadaEn: 'desc' },
}),
])
// Obtener nombres de usuarios top
const topUsuariosIds = porUsuario.map(p => p.usuarioId).filter(Boolean) as string[]
const topUsuarios = await ctx.prisma.usuario.findMany({
where: { id: { in: topUsuariosIds } },
select: { id: true, nombre: true },
})
const usuarioMap = new Map(topUsuarios.map(u => [u.id, u.nombre]))
return {
periodo: { desde: input.desde, hasta: input.hasta },
logs,
sesiones,
resumen: {
totalAcciones: logs.length,
totalSesiones: sesiones.length,
duracionTotalSesiones: sesiones.reduce((sum, s) => sum + (s.duracion || 0), 0),
porAccion: porAccion.map(a => ({ accion: a.accion, count: a._count })),
topUsuarios: porUsuario.map(u => ({
usuario: usuarioMap.get(u.usuarioId!) || 'Desconocido',
count: u._count,
})),
},
}
}),
// Exportar reporte a CSV
exportarCSV: protectedProcedure
.input(
z.object({
tipo: z.enum(['inventario', 'alertas', 'actividad']),
clienteId: z.string().optional(),
desde: z.date().optional(),
hasta: z.date().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input.clienteId
let data: string[][] = []
let headers: string[] = []
switch (input.tipo) {
case 'inventario': {
headers = ['Cliente', 'Tipo', 'Nombre', 'IP', 'SO', 'Serial', 'Estado']
const dispositivos = await ctx.prisma.dispositivo.findMany({
where: clienteId ? { clienteId } : {},
include: { cliente: { select: { nombre: true } } },
})
data = dispositivos.map(d => [
d.cliente.nombre,
d.tipo,
d.nombre,
d.ip || '',
d.sistemaOperativo || '',
d.serial || '',
d.estado,
])
break
}
case 'alertas': {
headers = ['Fecha', 'Cliente', 'Dispositivo', 'Severidad', 'Estado', 'Titulo', 'Mensaje']
const alertas = await ctx.prisma.alerta.findMany({
where: {
...(clienteId ? { clienteId } : {}),
...(input.desde || input.hasta ? {
createdAt: {
...(input.desde ? { gte: input.desde } : {}),
...(input.hasta ? { lte: input.hasta } : {}),
},
} : {}),
},
include: {
cliente: { select: { nombre: true } },
dispositivo: { select: { nombre: true } },
},
})
data = alertas.map(a => [
a.createdAt.toISOString(),
a.cliente.nombre,
a.dispositivo?.nombre || '',
a.severidad,
a.estado,
a.titulo,
a.mensaje,
])
break
}
case 'actividad': {
headers = ['Fecha', 'Usuario', 'Accion', 'Recurso', 'Dispositivo', 'IP']
const logs = await ctx.prisma.auditLog.findMany({
where: {
...(input.desde || input.hasta ? {
createdAt: {
...(input.desde ? { gte: input.desde } : {}),
...(input.hasta ? { lte: input.hasta } : {}),
},
} : {}),
},
include: {
usuario: { select: { nombre: true } },
dispositivo: { select: { nombre: true } },
},
})
data = logs.map(l => [
l.createdAt.toISOString(),
l.usuario?.nombre || '',
l.accion,
l.recurso,
l.dispositivo?.nombre || '',
l.ip || '',
])
break
}
}
// Generar CSV
const csv = [
headers.join(','),
...data.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(',')),
].join('\n')
return {
filename: `reporte-${input.tipo}-${new Date().toISOString().split('T')[0]}.csv`,
content: csv,
contentType: 'text/csv',
}
}),
})

View File

@@ -0,0 +1,322 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import bcrypt from 'bcryptjs'
import { router, protectedProcedure, adminProcedure, superAdminProcedure } from '../trpc'
export const usuariosRouter = router({
// Listar usuarios
list: adminProcedure
.input(
z.object({
clienteId: z.string().optional(),
rol: z.enum(['SUPER_ADMIN', 'ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']).optional(),
activo: z.boolean().optional(),
search: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, rol, activo, search, page = 1, limit = 20 } = input || {}
// Si no es super admin, solo puede ver usuarios de su cliente
const where = {
...(ctx.user.rol !== 'SUPER_ADMIN' && ctx.user.clienteId
? { clienteId: ctx.user.clienteId }
: {}),
...(clienteId ? { clienteId } : {}),
...(rol ? { rol } : {}),
...(activo !== undefined ? { activo } : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },
{ email: { contains: search, mode: 'insensitive' as const } },
],
} : {}),
}
const [usuarios, total] = await Promise.all([
ctx.prisma.usuario.findMany({
where,
include: {
cliente: { select: { id: true, nombre: true } },
},
orderBy: { nombre: 'asc' },
skip: (page - 1) * limit,
take: limit,
}),
ctx.prisma.usuario.count({ where }),
])
return {
usuarios: usuarios.map(u => ({
...u,
passwordHash: undefined, // No exponer hash
})),
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
}
}),
// Obtener usuario por ID
byId: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({
where: { id: input.id },
include: {
cliente: true,
permisos: true,
},
})
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
// Verificar acceso
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return {
...usuario,
passwordHash: undefined,
}
}),
// Crear usuario
create: adminProcedure
.input(
z.object({
email: z.string().email(),
nombre: z.string().min(1),
password: z.string().min(8).optional(),
clienteId: z.string().optional(),
rol: z.enum(['ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']),
telefono: z.string().optional(),
notificarEmail: z.boolean().default(true),
notificarSms: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// Solo super admin puede crear sin clienteId
if (!input.clienteId && ctx.user.rol !== 'SUPER_ADMIN') {
input.clienteId = ctx.user.clienteId!
}
// Verificar email unico
const existe = await ctx.prisma.usuario.findUnique({
where: { email: input.email },
})
if (existe) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Ya existe un usuario con ese email',
})
}
const passwordHash = input.password
? await bcrypt.hash(input.password, 12)
: null
return ctx.prisma.usuario.create({
data: {
email: input.email,
nombre: input.nombre,
passwordHash,
clienteId: input.clienteId,
rol: input.rol,
telefono: input.telefono,
notificarEmail: input.notificarEmail,
notificarSms: input.notificarSms,
},
})
}),
// Actualizar usuario
update: adminProcedure
.input(
z.object({
id: z.string(),
nombre: z.string().min(1).optional(),
email: z.string().email().optional(),
rol: z.enum(['ADMIN', 'TECNICO', 'CLIENTE', 'VIEWER']).optional(),
activo: z.boolean().optional(),
telefono: z.string().optional().nullable(),
notificarEmail: z.boolean().optional(),
notificarSms: z.boolean().optional(),
clienteId: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const usuario = await ctx.prisma.usuario.findUnique({ where: { id } })
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
// Verificar acceso
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// Solo super admin puede cambiar clienteId
if (data.clienteId !== undefined && ctx.user.rol !== 'SUPER_ADMIN') {
delete data.clienteId
}
return ctx.prisma.usuario.update({
where: { id },
data,
})
}),
// Resetear password
resetPassword: adminProcedure
.input(
z.object({
id: z.string(),
newPassword: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({ where: { id: input.id } })
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
const passwordHash = await bcrypt.hash(input.newPassword, 12)
await ctx.prisma.usuario.update({
where: { id: input.id },
data: { passwordHash },
})
return { success: true }
}),
// Eliminar usuario
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const usuario = await ctx.prisma.usuario.findUnique({ where: { id: input.id } })
if (!usuario) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Usuario no encontrado' })
}
if (
ctx.user.rol !== 'SUPER_ADMIN' &&
ctx.user.clienteId &&
usuario.clienteId !== ctx.user.clienteId
) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
// No permitir auto-eliminacion
if (usuario.id === ctx.user.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No puedes eliminar tu propio usuario',
})
}
return ctx.prisma.usuario.delete({ where: { id: input.id } })
}),
// Gestionar permisos
permisos: router({
list: adminProcedure
.input(z.object({ usuarioId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.usuarioPermiso.findMany({
where: { usuarioId: input.usuarioId },
})
}),
set: adminProcedure
.input(
z.object({
usuarioId: z.string(),
permisos: z.array(
z.object({
recurso: z.string(),
accion: z.string(),
permitido: z.boolean(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Eliminar permisos existentes
await ctx.prisma.usuarioPermiso.deleteMany({
where: { usuarioId: input.usuarioId },
})
// Crear nuevos permisos
await ctx.prisma.usuarioPermiso.createMany({
data: input.permisos.map(p => ({
usuarioId: input.usuarioId,
recurso: p.recurso,
accion: p.accion,
permitido: p.permitido,
})),
})
return { success: true }
}),
}),
// Crear super admin (solo para setup inicial)
createSuperAdmin: superAdminProcedure
.input(
z.object({
email: z.string().email(),
nombre: z.string().min(1),
password: z.string().min(8),
})
)
.mutation(async ({ ctx, input }) => {
const existe = await ctx.prisma.usuario.findUnique({
where: { email: input.email },
})
if (existe) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Ya existe un usuario con ese email',
})
}
const passwordHash = await bcrypt.hash(input.password, 12)
return ctx.prisma.usuario.create({
data: {
email: input.email,
nombre: input.nombre,
passwordHash,
rol: 'SUPER_ADMIN',
},
})
}),
})

87
src/server/trpc/trpc.ts Normal file
View File

@@ -0,0 +1,87 @@
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import { ZodError } from 'zod'
import { getSession } from '@/lib/auth'
import prisma from '@/lib/prisma'
import { SessionUser } from '@/types'
export interface Context {
user: SessionUser | null
prisma: typeof prisma
}
export async function createContext(): Promise<Context> {
const user = await getSession()
return {
user,
prisma,
}
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const router = t.router
export const publicProcedure = t.procedure
// Middleware de autenticacion
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const protectedProcedure = t.procedure.use(isAuthenticated)
// Middleware para admin
const isAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
}
if (!['SUPER_ADMIN', 'ADMIN'].includes(ctx.user.rol)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const adminProcedure = t.procedure.use(isAdmin)
// Middleware para super admin
const isSuperAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No autenticado' })
}
if (ctx.user.rol !== 'SUPER_ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Acceso denegado' })
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
})
})
export const superAdminProcedure = t.procedure.use(isSuperAdmin)