Files
FRASA-CAS/api-gateway/src/controllers/soporte.controller.ts

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)
}