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 = { '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// 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// */ 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 { const id = idSolicitudDe(req) let body: z.infer 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 { const { estado, departamento } = listarSolicitudSchema.parse(req.query) const where: Record = {} 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 { 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 { 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) }