feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol
This commit is contained in:
@@ -1,21 +1,44 @@
|
||||
import type { Request, Response } from 'express'
|
||||
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).
|
||||
* - GET /api/soporte — listar solicitudes: propias para cualquier usuario,
|
||||
* TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA).
|
||||
* - 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'),
|
||||
@@ -29,16 +52,104 @@ const crearSolicitudSchema = z.object({
|
||||
// username del token si viene vacío
|
||||
solicitante: z.string().trim().min(1).optional(),
|
||||
email: z.string().trim().email('Email inválido').optional(),
|
||||
// Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda
|
||||
// nombres; se guardan como JSON array)
|
||||
// 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({
|
||||
estado: z.enum(ESTADOS_SOPORTE).optional(),
|
||||
// '' = 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(),
|
||||
})
|
||||
|
||||
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */
|
||||
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
|
||||
@@ -57,24 +168,40 @@ function aPublico(fila: {
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}) {
|
||||
let archivos: string[] = []
|
||||
if (fila.archivos) {
|
||||
try {
|
||||
const parseado: unknown = JSON.parse(fila.archivos)
|
||||
if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string')
|
||||
} catch {
|
||||
archivos = []
|
||||
}
|
||||
}
|
||||
return { ...fila, archivos }
|
||||
return { ...fila, archivos: parsearArchivos(fila.archivos) }
|
||||
}
|
||||
|
||||
/** POST /api/soporte — crear solicitud de soporte */
|
||||
/** 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 body = crearSolicitudSchema.parse(req.body)
|
||||
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,
|
||||
@@ -84,7 +211,7 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
|
||||
fechaSolicitud: body.fechaSolicitud,
|
||||
fechaLimite: body.fechaLimite ?? null,
|
||||
notas: body.notas ?? null,
|
||||
archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null,
|
||||
archivos: archivos.length > 0 ? JSON.stringify(archivos) : null,
|
||||
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
|
||||
solicitanteEmail: body.email ?? null,
|
||||
},
|
||||
@@ -93,15 +220,18 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
|
||||
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
|
||||
}
|
||||
|
||||
/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */
|
||||
/**
|
||||
* 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 } = listarSolicitudSchema.parse(req.query)
|
||||
const { estado, departamento } = listarSolicitudSchema.parse(req.query)
|
||||
const where: Record<string, unknown> = {}
|
||||
// Admin ve todas; el resto solo las suyas
|
||||
if (req.usuarioToken?.rol !== 'admin') {
|
||||
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
||||
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
|
||||
}
|
||||
if (estado !== undefined) where['estado'] = estado
|
||||
if (estado) where['estado'] = estado
|
||||
if (departamento) where['departamento'] = departamento
|
||||
|
||||
const filas = await prisma.solicitudSoporte.findMany({
|
||||
where,
|
||||
@@ -109,3 +239,68 @@ export async function listarSolicitudesSoporteEndpoint(req: Request, res: Respon
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user