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:
263
src/server/trpc/routers/alertas.router.ts
Normal file
263
src/server/trpc/routers/alertas.router.ts
Normal 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 } })
|
||||
}),
|
||||
}),
|
||||
})
|
||||
173
src/server/trpc/routers/auth.router.ts
Normal file
173
src/server/trpc/routers/auth.router.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
382
src/server/trpc/routers/celulares.router.ts
Normal file
382
src/server/trpc/routers/celulares.router.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
264
src/server/trpc/routers/clientes.router.ts
Normal file
264
src/server/trpc/routers/clientes.router.ts
Normal 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 } })
|
||||
}),
|
||||
}),
|
||||
})
|
||||
370
src/server/trpc/routers/configuracion.router.ts
Normal file
370
src/server/trpc/routers/configuracion.router.ts
Normal 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}`,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
})
|
||||
333
src/server/trpc/routers/equipos.router.ts
Normal file
333
src/server/trpc/routers/equipos.router.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
24
src/server/trpc/routers/index.ts
Normal file
24
src/server/trpc/routers/index.ts
Normal 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
|
||||
305
src/server/trpc/routers/red.router.ts
Normal file
305
src/server/trpc/routers/red.router.ts
Normal 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 }
|
||||
}),
|
||||
})
|
||||
389
src/server/trpc/routers/reportes.router.ts
Normal file
389
src/server/trpc/routers/reportes.router.ts
Normal 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',
|
||||
}
|
||||
}),
|
||||
})
|
||||
322
src/server/trpc/routers/usuarios.router.ts
Normal file
322
src/server/trpc/routers/usuarios.router.ts
Normal 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
87
src/server/trpc/trpc.ts
Normal 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)
|
||||
Reference in New Issue
Block a user