feat(7-mejoras): notas persisten + soporte + captura visible

1. Notas propagan entre etapas (convertirLead/apartar updateMany)
2. Afluencia: conexión → aviso amarillo
3. Escrituración: click nombre abre bitácora
4. Co-acreditado: opción 'no tiene'
5. Nota del alta de lead → bitácora
6. Módulo Soporte: formulario + listado solicitudes
7. Captura visible para todos
This commit is contained in:
2026-09-11 23:17:25 +00:00
parent 1ba7aa49da
commit c8357fe582
22 changed files with 693 additions and 19 deletions

View File

@@ -0,0 +1,111 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.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).
*/
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 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/nombres de archivos adjuntos (sin endpoint de upload: el front manda
// nombres; se guardan como JSON array)
archivos: z.array(z.string().trim().min(1)).optional(),
})
const listarSolicitudSchema = z.object({
estado: z.enum(ESTADOS_SOPORTE).optional(),
})
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */
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
}) {
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 }
}
/** POST /api/soporte — crear solicitud de soporte */
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
const body = crearSolicitudSchema.parse(req.body)
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
const fila = await prisma.solicitudSoporte.create({
data: {
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: body.archivos !== undefined ? JSON.stringify(body.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 (propias; admin ve todas) */
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
const { estado } = listarSolicitudSchema.parse(req.query)
const where: Record<string, unknown> = {}
// Admin ve todas; el resto solo las suyas
if (req.usuarioToken?.rol !== 'admin') {
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
}
if (estado !== undefined) where['estado'] = estado
const filas = await prisma.solicitudSoporte.findMany({
where,
orderBy: { createdAt: 'desc' },
})
res.json(filas.map(aPublico))
}