feat(contactos): detalle completo del cliente con propiedad e historial

- GET /api/contactos/:id/detalle: todas las columnas del contacto, la
  propiedad comprada/en proceso (apartado relacionado por teléfono/email,
  enriquecido con el desarrollo de la unidad), quién atiende cada item
  relacionado (leads/oportunidades/apartados, columna people) y el
  historial completo de atención (activity log de los 4 tableros +
  capturas y alta local con nombre del capturista)
- monday.service: getContactoDetalle (columnas por título),
  getHistorialItems (todos los eventos del activity log por items) y
  ApartadoMonday enriquecido (teléfono, email, precio final, fecha compra)
- Landing: la lista de Contactos abre un modal por cliente con datos,
  propiedad, relaciones CRM (con quién atiende) y timeline de historial
- Tests: 465 API + 12 landing, verde
This commit is contained in:
2026-09-04 23:55:36 +00:00
parent 27ba9ea8fa
commit a4575bb948
8 changed files with 744 additions and 11 deletions

View File

@@ -1,5 +1,8 @@
import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { detalleContacto, claveTelefono } from '../services/contactos-detalle.service.js'
import { prisma } from '../services/prisma.js'
import { NotFoundError } from '../utils/errors.js'
/**
* GET /api/contactos (Bearer) → contactos del tablero Contactos de Monday
@@ -8,3 +11,84 @@ import { mondayService } from '../services/monday.service.js'
export async function listarContactosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await mondayService.getContactos())
}
/**
* GET /api/contactos/:id/detalle (Bearer) → ficha completa del contacto:
* todas sus columnas, la propiedad comprada/en proceso (apartado relacionado),
* quién atiende cada item relacionado (leads, oportunidades, apartados) y el
* historial completo de atención (activity log de Monday + capturas locales).
*/
export async function detalleContactoEndpoint(req: Request, res: Response): Promise<void> {
const contactoId = String(req.params['id'])
const contacto = await mondayService.getContactoDetalle(contactoId)
if (contacto === null) throw new NotFoundError('Contacto no encontrado')
// Nombres para "atendidoPor"/historial: usuarios del ecosistema + workspace
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
}
// Registro local: capturas con el mismo teléfono (Monday no guarda quién
// capturó) y la ficha de cliente ligada a este contacto (o por celular).
// LeadCaptura/Cliente no tienen relación Prisma a Usuario: se resuelve a mano.
const clave = claveTelefono(contacto['Teléfono'] ?? '')
const capturasTodas = await prisma.leadCaptura.findMany({
where: { telefono: { not: null } },
orderBy: { createdAt: 'desc' },
})
const capturasCrudas = capturasTodas.filter((c) => c.telefono !== null && clave !== '' && claveTelefono(c.telefono) === clave)
const clienteCrudo = await prisma.cliente.findFirst({
where: {
OR: [
{ mondayContactoId: contactoId },
...(clave !== '' ? [{ celular: { contains: clave } }] : []),
],
},
include: { _count: { select: { documentos: true } } },
orderBy: { createdAt: 'desc' },
})
// Nombres de capturistas (ids locales → nombre para mostrar)
const idsUsuarios = [
...new Set([
...capturasCrudas.map((c) => c.usuarioId),
...(clienteCrudo?.capturadoPorId ? [clienteCrudo.capturadoPorId] : []),
]),
]
const usuariosLocal = idsUsuarios.length
? await prisma.usuario.findMany({ where: { id: { in: idsUsuarios } }, select: { id: true, nombre: true } })
: []
const nombreLocal = new Map(usuariosLocal.map((u) => [u.id, u.nombre]))
const capturas = capturasCrudas.map((c) => ({
leadId: c.leadId,
nombreLead: c.nombreLead,
createdAt: c.createdAt,
usuario: nombreLocal.has(c.usuarioId) ? { nombre: nombreLocal.get(c.usuarioId)! } : null,
}))
const clienteLocal = clienteCrudo
? {
...clienteCrudo,
capturadoPor:
clienteCrudo.capturadoPorId && nombreLocal.has(clienteCrudo.capturadoPorId)
? { nombre: nombreLocal.get(clienteCrudo.capturadoPorId)! }
: null,
}
: null
res.json(await detalleContacto(mondayService, contactoId, nombresPorMondayId, capturas, clienteLocal))
}