import type { Request, Response } from 'express' import { randomUUID } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' import multer from 'multer' import { z } from 'zod' import { config } from '../config/index.js' import { mondayService, type MondayService } from '../services/monday.service.js' import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js' import { prisma } from '../services/prisma.js' import { saludMonday } from '../services/salud-monday.service.js' import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' import { esFalloMonday } from '../services/sync-monday.service.js' import { checkboxParaTipoDocumento } from './clientes.controller.js' import * as auditoria from '../services/auditoria.service.js' /** * Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede * mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES * de la columna status 'Estado' del board (verificados en Monday) — el * frontend los manda tal cual. */ export const FASES_APARTADO_PERMITIDAS = [ 'Pago Apartado', 'Alta Enkontrol', 'Entrego Documentacion', 'Cita Clientes', 'Contrato Enkontrol', 'Firma Cliente (Venta)', 'Cancelacion Apartado', ] as const const cambiarEstadoSchema = z.object({ estado: z.enum(FASES_APARTADO_PERMITIDAS), }) type MondayApartados = Pick< MondayService, 'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo' > /** * Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que * corresponde (idempotente; el webhook de change haría lo mismo, pero así la * UI responde al instante). */ export async function cambiarEstadoApartado( monday: MondayApartados, itemId: string, estado: (typeof FASES_APARTADO_PERMITIDAS)[number], ): Promise { const boardApartado = config.monday.boards.apartado await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado) await moverAGrupoSegunEstado(monday, boardApartado, itemId) } /** POST /api/apartados/:itemId/estado { estado } */ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise { const body = cambiarEstadoSchema.parse(req.body) const itemId = String(req.params['itemId']) // Modo local (id loc_* o Monday caído / fallback 502): fase en la fila local // + CAMBIO_ETAPA encolado; ids Monday viejos sin fila crean sombra if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { usuarioId: req.usuarioToken?.sub, sombra: !esIdLocal(itemId), }) } else { try { await cambiarEstadoApartado(mondayService, itemId, body.estado) } catch (err) { if (!esFalloMonday(err)) throw err await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { usuarioId: req.usuarioToken?.sub, sombra: true, }) } } res.json({ itemId, estado: body.estado }) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'apartado', itemId, body.estado) } // ---------- Precarga de documentos del expediente (desde "Mis apartados") ---------- /** * Tipos de documento de la precarga de apartados. Los dos primeros son los * obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP, * comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart. */ export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'] as const const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']) const EXT_POR_MIME_APARTADO: Record = { 'application/pdf': 'pdf', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', } // Storage propio: archiva por item de Monday (uploads/apartados//) const storageApartados = multer.diskStorage({ destination: (req, _file, cb) => { const dir = path.join(config.uploadsDir, 'apartados', String(req.params['itemId'])) fs.mkdirSync(dir, { recursive: true }) cb(null, dir) }, filename: (req, file, cb) => { const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase() const ext = EXT_POR_MIME_APARTADO[file.mimetype] ?? 'bin' cb(null, `${tipo}_${randomUUID()}.${ext}`) }, }) export const uploadDocumentosApartado = multer({ storage: storageApartados, limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10 fileFilter: (_req, file, cb) => { if (!MIME_PERMITIDOS_APARTADO.has(file.mimetype)) { cb(new Error(`Tipo de archivo no permitido: ${file.mimetype}`)) return } cb(null, true) }, }) /** * Cliente local ligado al apartado de Monday; si aún no existe (apartado * creado sin pasar por el alta progresiva), se crea la ficha mínima para que * los documentos tengan dónde vivir. El nombre se toma de Monday; si Monday * está caído o el id es local (loc_*), se usa el de la fila LeadCaptura del * apartado (board APARTADO, creada por el "Aparto" local) como fallback. */ async function clienteDeApartado(itemId: string, capturadoPorId?: string) { const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } }) if (existente) return existente let nombre: string | null = null if (!esIdLocal(itemId) && saludMonday.estaDisponible()) { try { nombre = await mondayService.obtenerNombreItem(itemId) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo leer el nombre del apartado ${itemId} en Monday; se usa el nombre local:`, err) } } if (nombre === null) { const captura = await prisma.leadCaptura.findFirst({ where: { OR: [{ leadId: itemId }, { mondayItemId: itemId }] }, }) nombre = captura?.nombreLead ?? 'Cliente' } return prisma.cliente.create({ data: { nombreCompleto: nombre, curp: '', rfc: '', proyecto: '', payloadJson: '{}', mondayApartadoId: itemId, capturadoPorId: capturadoPorId ?? null, }, }) } /** * POST /api/apartados/:itemId/documentos — multipart, campo `archivos` (hasta * 10) + campo `tipo`. Los documentos se guardan en el CLIENTE ligado al * apartado (Documento.clienteId); si el tipo tiene checkbox en el tablero * Apartado (CURP/RFC/Comprobante), se prende en Monday. */ export async function subirDocumentosApartado(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) const cliente = await clienteDeApartado(itemId, req.usuarioToken?.sub) const archivos = (req.files ?? []) as Express.Multer.File[] const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase() const documentos = await Promise.all( archivos.map((f) => prisma.documento.create({ data: { clienteId: cliente.id, tipo, nombreArchivo: f.originalname, ruta: path.relative(config.uploadsDir, f.path), mime: f.mimetype, tamano: f.size, }, }), ), ) // Prende el checkbox del documento en el item de Apartado (si el tipo tiene // checkbox); un fallo de Monday no rompe la subida: queda encolado // (CHECKBOX_DOC) y el worker lo aplica al sincronizar. const checkbox = checkboxParaTipoDocumento(tipo) if (checkbox !== null) { if (esIdLocal(itemId) || !saludMonday.estaDisponible()) { await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId) } else { try { await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true) } catch (err) { // eslint-disable-next-line no-console console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err) await encolarSyncSeguro('CHECKBOX_DOC', { checkbox, itemId }, itemId) } } } res.status(201).json(documentos) } /** GET /api/apartados/:itemId/documentos — documentos del cliente ligado (vacío si aún no hay) */ export async function listarDocumentosApartado(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) const cliente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } }) if (!cliente) { res.json([]) return } const documentos = await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' }, }) res.json(documentos) }