307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import type { NextFunction, Request, Response } from 'express'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
import multer from 'multer'
|
|
import { z } from 'zod'
|
|
import { config } from '../config/index.js'
|
|
import { prisma } from '../services/prisma.js'
|
|
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
|
import { esAdminOManager } from '../utils/roles.js'
|
|
import * as auditoria from '../services/auditoria.service.js'
|
|
|
|
/**
|
|
* Módulo Soporte: solicitudes dirigidas a los departamentos internos
|
|
* (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección).
|
|
*
|
|
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado). Acepta
|
|
* multipart con los adjuntos reales en el campo `archivos` (PDF/JPG/PNG,
|
|
* máx 10 MB c/u, hasta 10) o JSON sin adjuntos.
|
|
* - GET /api/soporte — listar solicitudes: propias para un vendedor, TODAS
|
|
* para admin y manager, con filtros opcionales ?estado= y ?departamento=.
|
|
* - GET /api/soporte/:id/archivos/:indice — descargar un adjunto (dueño,
|
|
* admin o manager).
|
|
* - PATCH /api/soporte/:id — cambiar el estado (solo admin/manager).
|
|
*/
|
|
|
|
const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const
|
|
const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
|
|
const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
|
|
|
|
const MIME_PERMITIDOS_SOPORTE = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
|
const EXT_POR_MIME_SOPORTE: Record<string, string> = {
|
|
'application/pdf': 'pdf',
|
|
'image/jpeg': 'jpg',
|
|
'image/jpg': 'jpg',
|
|
'image/png': 'png',
|
|
}
|
|
|
|
const MAX_ARCHIVOS_SOPORTE = 10
|
|
const MAX_BYTES_SOPORTE = 10 * 1024 * 1024
|
|
|
|
const crearSolicitudSchema = z.object({
|
|
titulo: z.string().trim().min(1, 'El título es obligatorio'),
|
|
descripcion: z.string().trim().min(1, 'La descripción es obligatoria'),
|
|
departamento: z.enum(DEPARTAMENTOS_SOPORTE),
|
|
prioridad: z.enum(PRIORIDADES_SOPORTE),
|
|
responsable: z.string().trim().min(1).optional(),
|
|
fechaSolicitud: z.coerce.date(),
|
|
fechaLimite: z.coerce.date().optional(),
|
|
notas: z.string().trim().min(1).optional(),
|
|
// Quién pide la solicitud; el front lo manda desde la sesión y el API cae al
|
|
// username del token si viene vacío
|
|
solicitante: z.string().trim().min(1).optional(),
|
|
email: z.string().trim().email('Email inválido').optional(),
|
|
// Rutas relativas de adjuntos YA subidos; con multipart se ignoran y se toman
|
|
// los archivos reales que entregó multer (nadie declara rutas a mano)
|
|
archivos: z.array(z.string().trim().min(1)).optional(),
|
|
})
|
|
|
|
const listarSolicitudSchema = z.object({
|
|
// '' = sin filtro: el front a veces manda el select vacío
|
|
estado: z.enum(ESTADOS_SOPORTE).or(z.literal('')).optional(),
|
|
departamento: z.enum(DEPARTAMENTOS_SOPORTE).or(z.literal('')).optional(),
|
|
})
|
|
|
|
const cambiarEstadoSchema = z.object({
|
|
estado: z.enum(ESTADOS_SOPORTE),
|
|
})
|
|
|
|
/** Request con el id de la solicitud ya reservado para la carpeta de adjuntos */
|
|
type RequestConIdSoporte = Request & { solicitudSoporteId?: string }
|
|
|
|
/**
|
|
* Id de la solicitud en curso. Se reserva ANTES de multer para que los adjuntos
|
|
* aterricen en uploads/soporte/<id>/ con el mismo id que tendrá la fila.
|
|
*/
|
|
function idSolicitudDe(req: Request): string {
|
|
const conId = req as RequestConIdSoporte
|
|
conId.solicitudSoporteId ??= randomUUID()
|
|
return conId.solicitudSoporteId
|
|
}
|
|
|
|
/** Carpeta de adjuntos de una solicitud: uploads/soporte/<id>/ */
|
|
function carpetaSoporte(id: string): string {
|
|
return path.join(config.uploadsDir, 'soporte', id)
|
|
}
|
|
|
|
/** Nombre seguro y único: uuid corto + nombre original saneado (sin rutas) */
|
|
function nombreSeguro(original: string): string {
|
|
const base = path.basename(original).replace(/[^\w.\- ]+/g, '_').slice(-80).trim()
|
|
return base.length > 0 ? base : 'archivo'
|
|
}
|
|
|
|
const storageSoporte = multer.diskStorage({
|
|
destination: (req, _file, cb) => {
|
|
const dir = carpetaSoporte(idSolicitudDe(req))
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
cb(null, dir)
|
|
},
|
|
filename: (_req, file, cb) => {
|
|
const ext = EXT_POR_MIME_SOPORTE[file.mimetype] ?? 'bin'
|
|
const base = nombreSeguro(file.originalname)
|
|
cb(null, `${randomUUID().slice(0, 8)}-${base.endsWith(`.${ext}`) ? base : `${base}.${ext}`}`)
|
|
},
|
|
})
|
|
|
|
const multerArchivosSoporte = multer({
|
|
storage: storageSoporte,
|
|
limits: { fileSize: MAX_BYTES_SOPORTE, files: MAX_ARCHIVOS_SOPORTE },
|
|
fileFilter: (_req, file, cb) => {
|
|
if (!MIME_PERMITIDOS_SOPORTE.has(file.mimetype)) {
|
|
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype} (solo PDF, JPG o PNG)`))
|
|
return
|
|
}
|
|
cb(null, true)
|
|
},
|
|
}).array('archivos', MAX_ARCHIVOS_SOPORTE)
|
|
|
|
/**
|
|
* Middleware de subida: recibe hasta 10 adjuntos en el campo `archivos`. Si
|
|
* multer corta a mitad (tipo no permitido, archivo > 10 MB, demasiados
|
|
* archivos) borra lo que haya quedado en la carpeta de la solicitud.
|
|
*/
|
|
export function uploadArchivosSoporte(req: Request, res: Response, next: NextFunction): void {
|
|
multerArchivosSoporte(req, res, (err?: unknown) => {
|
|
if (err) {
|
|
borrarCarpetaSoporte(idSolicitudDe(req))
|
|
next(err)
|
|
return
|
|
}
|
|
next()
|
|
})
|
|
}
|
|
|
|
/** Borra la carpeta de adjuntos de una solicitud que no llegó a crearse */
|
|
function borrarCarpetaSoporte(id: string): void {
|
|
fs.rmSync(carpetaSoporte(id), { recursive: true, force: true })
|
|
}
|
|
|
|
/** archivos JSON → array de rutas relativas (tolerante a basura) */
|
|
function parsearArchivos(archivos: string | null): string[] {
|
|
if (!archivos) return []
|
|
try {
|
|
const parseado: unknown = JSON.parse(archivos)
|
|
if (Array.isArray(parseado)) return parseado.filter((a): a is string => typeof a === 'string')
|
|
return []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** Serializa la fila para el front: archivos JSON → array de rutas */
|
|
function aPublico(fila: {
|
|
id: string
|
|
usuarioId: string
|
|
titulo: string
|
|
descripcion: string
|
|
departamento: string
|
|
prioridad: string
|
|
responsable: string | null
|
|
fechaSolicitud: Date
|
|
fechaLimite: Date | null
|
|
notas: string | null
|
|
archivos: string | null
|
|
solicitante: string
|
|
solicitanteEmail: string | null
|
|
estado: string
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}) {
|
|
return { ...fila, archivos: parsearArchivos(fila.archivos) }
|
|
}
|
|
|
|
/** MIME por extensión para servir el adjunto descargado */
|
|
function mimeDeArchivo(ruta: string): string {
|
|
const ext = path.extname(ruta).toLowerCase()
|
|
if (ext === '.pdf') return 'application/pdf'
|
|
if (ext === '.png') return 'image/png'
|
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
|
return 'application/octet-stream'
|
|
}
|
|
|
|
/** POST /api/soporte — crear solicitud de soporte (JSON o multipart) */
|
|
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
|
const id = idSolicitudDe(req)
|
|
let body: z.infer<typeof crearSolicitudSchema>
|
|
try {
|
|
body = crearSolicitudSchema.parse(req.body)
|
|
} catch (err) {
|
|
// Los adjuntos ya están en disco: sin solicitud que los referencie, sobran
|
|
borrarCarpetaSoporte(id)
|
|
throw err
|
|
}
|
|
|
|
const subidos = (req.files ?? []) as Express.Multer.File[]
|
|
const archivos =
|
|
subidos.length > 0
|
|
? subidos.map((f) => path.relative(config.uploadsDir, f.path))
|
|
: (body.archivos ?? [])
|
|
|
|
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
|
|
const fila = await prisma.solicitudSoporte.create({
|
|
data: {
|
|
id,
|
|
usuarioId,
|
|
titulo: body.titulo,
|
|
descripcion: body.descripcion,
|
|
departamento: body.departamento,
|
|
prioridad: body.prioridad,
|
|
responsable: body.responsable ?? null,
|
|
fechaSolicitud: body.fechaSolicitud,
|
|
fechaLimite: body.fechaLimite ?? null,
|
|
notas: body.notas ?? null,
|
|
archivos: archivos.length > 0 ? JSON.stringify(archivos) : null,
|
|
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
|
|
solicitanteEmail: body.email ?? null,
|
|
},
|
|
})
|
|
res.status(201).json(aPublico(fila))
|
|
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
|
|
}
|
|
|
|
/**
|
|
* GET /api/soporte — listar solicitudes. Admin y manager ven TODAS (la UI les
|
|
* dice "Todas las solicitudes"); un vendedor solo ve las suyas.
|
|
*/
|
|
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
|
const { estado, departamento } = listarSolicitudSchema.parse(req.query)
|
|
const where: Record<string, unknown> = {}
|
|
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
|
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
|
|
}
|
|
if (estado) where['estado'] = estado
|
|
if (departamento) where['departamento'] = departamento
|
|
|
|
const filas = await prisma.solicitudSoporte.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
res.json(filas.map(aPublico))
|
|
}
|
|
|
|
/** Middleware: solo admin/manager pueden mover solicitudes de estado */
|
|
export function requireAdminOManager(req: Request, _res: Response, next: NextFunction): void {
|
|
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
|
throw new AppError(403, 'Se requiere rol admin o manager')
|
|
}
|
|
next()
|
|
}
|
|
|
|
/** PATCH /api/soporte/:id — cambiar estado (admin/manager) */
|
|
export async function cambiarEstadoSolicitudEndpoint(req: Request, res: Response): Promise<void> {
|
|
const id = String(req.params['id'])
|
|
const { estado } = cambiarEstadoSchema.parse(req.body)
|
|
|
|
const existente = await prisma.solicitudSoporte.findUnique({ where: { id } })
|
|
if (!existente) throw new NotFoundError('Solicitud no encontrada')
|
|
|
|
const fila = await prisma.solicitudSoporte.update({ where: { id }, data: { estado } })
|
|
res.json(aPublico(fila))
|
|
auditoria.registrar(
|
|
req.usuarioToken?.sub ?? 'desconocido',
|
|
'CAMBIAR_ESTADO_SOPORTE',
|
|
'soporte',
|
|
fila.id,
|
|
`${existente.estado}→${estado}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* GET /api/soporte/:id/archivos/:indice — descarga un adjunto de la solicitud.
|
|
* Solo el dueño, un admin o un manager. El índice es la posición en
|
|
* `archivos`, así el cliente nunca propone una ruta.
|
|
*/
|
|
export async function descargarArchivoSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
|
const id = String(req.params['id'])
|
|
const indice = Number(req.params['indice'])
|
|
if (!Number.isInteger(indice) || indice < 0) {
|
|
throw new BadRequestError('Índice de archivo inválido')
|
|
}
|
|
|
|
const fila = await prisma.solicitudSoporte.findUnique({ where: { id } })
|
|
if (!fila) throw new NotFoundError('Solicitud no encontrada')
|
|
|
|
const propio = fila.usuarioId === (req.usuarioToken?.sub ?? 'desconocido')
|
|
if (!propio && !esAdminOManager(req.usuarioToken?.rol)) {
|
|
throw new AppError(403, 'Sin permiso para ver los adjuntos de esta solicitud')
|
|
}
|
|
|
|
const archivo = parsearArchivos(fila.archivos)[indice]
|
|
if (archivo === undefined) throw new NotFoundError('Archivo no encontrado')
|
|
|
|
// Guarda anti path-traversal: la ruta guardada debe vivir dentro de uploadsDir
|
|
const rutaAbsoluta = path.resolve(config.uploadsDir, archivo)
|
|
if (!rutaAbsoluta.startsWith(config.uploadsDir + path.sep)) {
|
|
throw new BadRequestError('Ruta de archivo inválida')
|
|
}
|
|
if (!fs.existsSync(rutaAbsoluta)) throw new NotFoundError('Archivo no encontrado en disco')
|
|
|
|
res.setHeader('Content-Type', mimeDeArchivo(rutaAbsoluta))
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename="${path.basename(rutaAbsoluta).replace(/[^\w.\- ]+/g, '_')}"`,
|
|
)
|
|
res.sendFile(rutaAbsoluta)
|
|
}
|