feat(alta): guardado progresivo por etapa con réplica a Monday

- Al avanzar cada paso del wizard: upsert silencioso del borrador local
  + sync a Monday (iniciar → Contacto en PROSPECTO; pasos siguientes →
  PATCH con los campos mapeables: datos generales, dirección armada,
  actividad económica)
- Al completar: Contacto pasa a CLIENTE + Apartado + funnel (dedup)
- progresoId viaja con el borrador (se restaura solo); fallos de sync
  no bloquean el avance (aviso ámbar no bloqueante); submit final con
  fallback al endpoint legado si nunca hubo sync
- API: POST /clientes/progreso/iniciar, PATCH /clientes/progreso/:id,
  POST /clientes/progreso/:id/completar; POST /clientes legado intacto
- Tests: API 338 (+16), alta-clientes 112 (+4); smoke en vivo
  verificado (PROSPECTO → patch → CLIENTE + Apartado) y limpiado
This commit is contained in:
2026-09-01 21:03:47 +00:00
parent cd93aec91d
commit ccd4ca78ca
11 changed files with 876 additions and 40 deletions

View File

@@ -0,0 +1,306 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { contactoDesdeDatos, textoDireccion } from './clientes.controller.js'
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
crearContacto: vi.fn(),
actualizarContacto: vi.fn(),
crearApartado: vi.fn(),
}))
const funnelMock = vi.hoisted(() => ({
moverAApartoConDedup: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
const reqFake = (body: unknown, params: Record<string, string> = {}): Request =>
({ body, params }) as unknown as Request
const resFake = () => {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const datosWizard = {
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
email: 'ana@correo.com',
celular: '6649990003',
estadoCivil: 'SOLTERO',
tipoVivienda: 'RENTADA',
tipoCompra: 'VIVIR_AHI',
fechaNacimiento: '1990-01-01',
},
empleo: { tipoEmpleo: 'ASALARIADO' },
viviendaCredito: {
proyecto: 'DIAGONAL',
domicilioFiscal: {
calle: 'Av. Siempre Viva',
numExt: '123',
numInt: '4',
colonia: 'Col. Centro',
cp: '22000',
municipio: 'Tijuana',
estado: 'Baja California',
},
},
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
mondayMock.crearContacto.mockResolvedValue('cont-1')
mondayMock.actualizarContacto.mockResolvedValue(undefined)
mondayMock.crearApartado.mockResolvedValue('ap-1')
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
})
describe('textoDireccion (domicilio del wizard → columna Dirección)', () => {
it('arma calle con ext/int, colonia, CP, municipio y estado', () => {
expect(
textoDireccion({
calle: 'Av. Siempre Viva',
numExt: '123',
numInt: '4',
colonia: 'Col. Centro',
cp: '22000',
municipio: 'Tijuana',
estado: 'Baja California',
}),
).toBe('Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California')
})
it('omite las partes vacías y devuelve undefined si no hay nada', () => {
expect(textoDireccion({ calle: 'Calle Uno', municipio: 'Tijuana' })).toBe('Calle Uno, Tijuana')
expect(textoDireccion({})).toBeUndefined()
expect(textoDireccion({ calle: ' ' })).toBeUndefined()
})
})
describe('contactoDesdeDatos (estado del wizard → campos del Contacto)', () => {
it('deriva todos los campos mapeables presentes', () => {
const c = contactoDesdeDatos(datosWizard)
expect(c).toMatchObject({
nombre: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
email: 'ana@correo.com',
telefono: '6649990003',
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
estadoCivilLabel: 'Soltero',
viviendaActualLabel: 'Rentada',
propositoCompraLabel: 'Habitarla',
rangoEdadLabel: 'Entre 24 y 40',
fechaNacimiento: '1990-01-01',
actividadEconomicaLabel: 'Empleado',
})
})
it('solo incluye campos presentes (no inventa datos)', () => {
const c = contactoDesdeDatos({ datosGenerales: { nombreCompleto: 'Solo Nombre' } })
expect(c).toEqual({ nombre: 'Solo Nombre' })
})
it('sin domicilioFiscal usa el texto libre datosGenerales.direccion', () => {
const c = contactoDesdeDatos({
datosGenerales: { nombreCompleto: 'Ana', direccion: 'Calle Libre 1' },
})
expect(c.direccion).toBe('Calle Libre 1')
})
})
describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => {
it('crea el Cliente local y el Contacto en Monday con Estado PROSPECTO', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status, json } = resFake()
await iniciarProgreso(
reqFake({
datosGenerales: {
nombreCompleto: 'Ana Demo Progreso',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
celular: '6649990003',
},
}),
res,
)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: 'cont-1' })
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
nombre: 'Ana Demo Progreso',
estadoLabel: 'PROSPECTO',
curp: 'AEDA900101MBCXXX00',
rfc: 'AEDA900101XX0',
telefono: '6649990003',
})
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { mondayContactoId: 'cont-1' },
})
})
it('requiere solo datosGenerales.nombreCompleto', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
const { res, status } = resFake()
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
nombre: 'Solo Nombre',
estadoLabel: 'PROSPECTO',
})
await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow()
})
it('fallo de Monday: crea el local igual y devuelve mondayContactoId null', async () => {
const { iniciarProgreso } = await import('./clientes.controller.js')
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
const { res, status, json } = resFake()
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
})
})
describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => {
it('deriva los campos presentes y actualiza el Contacto (incl. Dirección armada)', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'cli-1' },
data: { payloadJson: JSON.stringify(datosWizard) },
})
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
'cont-1',
expect.objectContaining({
nombre: 'Ana Demo Progreso',
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
actividadEconomicaLabel: 'Empleado',
telefono: '6649990003',
}),
)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: true })
})
it('sin mondayContactoId responde mondaySync: false y no toca Monday', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: null })
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('fallo de Monday: 200 igual con mondaySync: false', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
mondayMock.actualizarContacto.mockRejectedValue(new Error('Monday caído'))
const { res, json } = resFake()
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
})
it('rechaza un body que no sea objeto', async () => {
const { actualizarProgreso } = await import('./clientes.controller.js')
await expect(actualizarProgreso(reqFake('no-objeto', { id: 'cli-1' }), resFake().res)).rejects.toThrow()
})
})
describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () => {
const altaCompleta = {
...datosWizard,
viviendaCredito: {
...datosWizard.viviendaCredito,
ubicacion: 'U-999-DEMO2',
valorVenta: 1500000,
enganche: 20000,
},
firma: { firmaDataUrl: 'data:image/png;base64,AAAA' },
leadMondayId: 'lead-9',
}
it('pasa el Contacto a CLIENTE, crea el Apartado y mueve el lead a Aparto (dedup)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
const { prismaStateStore } = await import('../services/automatizaciones.service.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: null,
})
const { res, status, json } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
'cont-1',
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
)
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
expect.objectContaining({
clienteNombre: 'Ana Demo Progreso',
unidadCodigo: 'U-999-DEMO2',
montoApartado: 20000,
estadoLabel: 'Pago Apartado',
}),
)
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
expect(json.mock.calls[0]?.[0]).toMatchObject({
id: 'cli-1',
mondayContactoId: 'cont-1',
mondayApartadoId: 'ap-1',
})
})
it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({
id: 'cli-1',
mondayContactoId: null,
mondayApartadoId: null,
})
const { res, status } = resFake()
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
expect(mondayMock.crearContacto).toHaveBeenCalledWith(
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
)
})
it('rechaza un payload incompleto (misma validación que POST /clientes)', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
await expect(
completarProgreso(
reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }),
resFake().res,
),
).rejects.toThrow()
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
})
it('404 si el progreso no existe', async () => {
const { completarProgreso } = await import('./clientes.controller.js')
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(
completarProgreso(reqFake(altaCompleta, { id: 'no-existe' }), resFake().res),
).rejects.toThrow('Cliente no encontrado')
})
})

View File

@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService } from '../services/monday.service.js'
import { mondayService, type DatosContacto } from '../services/monday.service.js'
import { funnelService } from '../services/funnel.service.js'
import { prismaStateStore } from '../services/automatizaciones.service.js'
import { prisma } from '../services/prisma.js'
@@ -157,6 +157,45 @@ const hoyMas = (dias: number): string => {
return `${d.getFullYear()}-${m}-${dia}`
}
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
/**
* Si el alta viene de un lead del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
* Un fallo aquí no rompe el alta.
*/
async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<void> {
const leadMondayId = body['leadMondayId']
if (typeof leadMondayId === 'string' && leadMondayId !== '') {
try {
await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err)
}
}
}
/** Item en el tablero Apartado (pipeline Enkontrol); solo si hay valor de venta */
async function crearApartadoSiAplica(
dg: { nombreCompleto: string },
vc: { ubicacion?: string | undefined; valorVenta?: string | number | undefined; enganche?: string | number | undefined },
): Promise<string | null> {
const valorVenta = aNumero(vc.valorVenta)
if (valorVenta <= 0) return null
const enganche = aNumero(vc.enganche)
return mondayService.crearApartado({
clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion ?? '',
...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
})
}
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
@@ -177,20 +216,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
},
})
// Si el alta viene de un lead del funnel (la app alta-clientes manda
// leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
// moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
// cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
// Un fallo aquí no rompe el alta.
const leadMondayId = (body as Record<string, unknown>)['leadMondayId']
if (typeof leadMondayId === 'string' && leadMondayId !== '') {
try {
await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err)
}
}
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
let mondayContactoId: string | null = null
@@ -211,18 +237,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
})
const valorVenta = aNumero(vc.valorVenta)
if (valorVenta > 0) {
const enganche = aNumero(vc.enganche)
mondayApartadoId = await mondayService.crearApartado({
clienteNombre: dg.nombreCompleto,
unidadCodigo: vc.ubicacion ?? '',
...(enganche > 0 ? { montoApartado: enganche } : {}),
fechaApartado: hoyMas(0),
fechaLimite: hoyMas(15),
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
})
}
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
await prisma.cliente.update({
where: { id: cliente.id },
@@ -236,6 +251,209 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
}
// ---------- Alta progresiva (guardado etapa por etapa del wizard) ----------
/**
* POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son
* opcionales porque el Contacto PROSPECTO se crea en cuanto hay nombre.
*/
export const iniciarProgresoSchema = z
.object({
datosGenerales: z
.object({
nombreCompleto: z.string().min(1),
curp: z.string().optional(),
rfc: z.string().optional(),
email: z.string().optional(),
celular: z.string().optional(),
direccion: z.string().optional(),
fechaNacimiento: z.string().optional(),
estadoCivil: z.string().optional(),
tipoVivienda: z.string().optional(),
tipoCompra: z.string().optional(),
})
.passthrough(),
leadMondayId: z.string().optional(),
})
.passthrough()
/** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */
const actualizarProgresoSchema = z.object({}).passthrough()
const seccionDe = (datos: Record<string, unknown>, clave: string): Record<string, unknown> => {
const v = datos[clave]
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
}
const textoDe = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v : undefined
/** Texto de la columna Dirección del Contacto a partir del domicilio capturado */
export function textoDireccion(dom: Record<string, unknown>): string | undefined {
const t = (k: string): string => textoDe(dom[k])?.trim() ?? ''
const calleNum = [t('calle'), t('numExt')].filter((p) => p !== '').join(' ')
const numInt = t('numInt')
const linea1 = [calleNum, numInt !== '' ? `Int. ${numInt}` : ''].filter((p) => p !== '').join(', ')
const cp = t('cp')
const partes = [linea1, t('colonia'), cp !== '' ? `CP ${cp}` : '', t('municipio'), t('estado')].filter(
(p) => p !== '',
)
return partes.length > 0 ? partes.join(', ') : undefined
}
/**
* Deriva TODOS los campos mapeables del Contacto de Monday presentes en el
* estado del wizard (idempotente: se manda el estado completo en cada avance).
*/
export function contactoDesdeDatos(datos: Record<string, unknown>): Omit<DatosContacto, 'estadoLabel'> {
const dg = seccionDe(datos, 'datosGenerales')
const empleo = seccionDe(datos, 'empleo')
const vc = seccionDe(datos, 'viviendaCredito')
// El domicilio puede venir suelto ('domicilio') o como domicilioFiscal del paso Vivienda
const domRaiz = seccionDe(datos, 'domicilio')
const dom = Object.keys(domRaiz).length > 0 ? domRaiz : seccionDe(vc, 'domicilioFiscal')
const curp = textoDe(dg['curp'])
const rfc = textoDe(dg['rfc'])
const email = textoDe(dg['email'])
const telefono = textoDe(dg['celular'])
const direccion = textoDireccion(dom) ?? textoDe(dg['direccion'])
const actividadEconomica = actividadEconomicaLabel(textoDe(empleo['tipoEmpleo']))
return {
nombre: textoDe(dg['nombreCompleto']) ?? '',
...(curp !== undefined ? { curp } : {}),
...(rfc !== undefined ? { rfc } : {}),
...(email !== undefined ? { email } : {}),
...(telefono !== undefined ? { telefono } : {}),
...(direccion !== undefined ? { direccion } : {}),
...perfilDemografico({
estadoCivil: textoDe(dg['estadoCivil']),
tipoVivienda: textoDe(dg['tipoVivienda']),
tipoCompra: textoDe(dg['tipoCompra']),
fechaNacimiento: textoDe(dg['fechaNacimiento']),
}),
...(actividadEconomica !== undefined ? { actividadEconomicaLabel: actividadEconomica } : {}),
}
}
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso + Contacto PROSPECTO */
export async function iniciarProgreso(req: Request, res: Response): Promise<void> {
const body = iniciarProgresoSchema.parse(req.body)
const dg = body.datosGenerales
const cliente = await prisma.cliente.create({
data: {
nombreCompleto: dg.nombreCompleto,
curp: dg.curp ?? '',
rfc: dg.rfc ?? '',
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: '', // se define al completar el alta
payloadJson: JSON.stringify(body),
},
})
// Contacto PROSPECTO en Monday; si falla, el progreso local ya quedó
let mondayContactoId: string | null = null
try {
mondayContactoId = await mondayService.crearContacto({
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'PROSPECTO',
})
await prisma.cliente.update({ where: { id: cliente.id }, data: { mondayContactoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo crear el Contacto PROSPECTO en Monday:', err)
}
res.status(201).json({ id: cliente.id, mondayContactoId })
}
/** PATCH /api/clientes/progreso/:id — guarda el avance y lo replica en el Contacto */
export async function actualizarProgreso(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const body = actualizarProgresoSchema.parse(req.body)
const cliente = await prisma.cliente.findUnique({ where: { id } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
await prisma.cliente.update({ where: { id }, data: { payloadJson: JSON.stringify(body) } })
// Réplica idempotente en Monday; un fallo no rompe el avance del wizard
let mondaySync = false
if (cliente.mondayContactoId !== null) {
try {
await mondayService.actualizarContacto(cliente.mondayContactoId, contactoDesdeDatos(body))
mondaySync = true
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo actualizar el Contacto ${cliente.mondayContactoId} en Monday:`, err)
}
}
res.json({ id, mondaySync })
}
/**
* POST /api/clientes/progreso/:id/completar — cierra el alta iniciada con el
* guardado progresivo. Misma validación y shape de respuesta que POST /clientes.
*/
export async function completarProgreso(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
const vc = body.viviendaCredito
const cliente = await prisma.cliente.findUnique({ where: { id } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const actualizado = await prisma.cliente.update({
where: { id },
data: {
nombreCompleto: dg.nombreCompleto,
curp: dg.curp,
rfc: dg.rfc,
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: vc.proyecto,
unidadCodigo: vc.ubicacion ?? '',
payloadJson: JSON.stringify(body),
firmaPng: body.firma?.firmaDataUrl ?? null,
},
})
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
// El Contacto (creado como PROSPECTO al iniciar) pasa a CLIENTE con los
// datos finales; si no existe (nunca hubo Monday), se crea como en el alta directa
let mondayContactoId: string | null = cliente.mondayContactoId
let mondayApartadoId: string | null = cliente.mondayApartadoId
try {
if (mondayContactoId !== null) {
await mondayService.actualizarContacto(mondayContactoId, {
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'CLIENTE',
})
} else {
mondayContactoId = await mondayService.crearContacto({
...contactoDesdeDatos(body as Record<string, unknown>),
estadoLabel: 'CLIENTE',
})
}
if (mondayApartadoId === null) {
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
}
await prisma.cliente.update({
where: { id },
data: { mondayContactoId, mondayApartadoId },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudo replicar el cliente en Monday:', err)
}
res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId })
}
/** GET /api/clientes/:id */
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
const cliente = await prisma.cliente.findUnique({

View File

@@ -10,7 +10,10 @@ import {
obtenerCotizacionPublica,
} from '../controllers/cotizaciones.controller.js'
import {
actualizarProgreso,
completarProgreso,
crearCliente,
iniciarProgreso,
listarDocumentos,
obtenerCliente,
subirDocumentos,
@@ -90,6 +93,11 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente)
// Alta progresiva: guardado etapa por etapa del wizard (Contacto PROSPECTO → CLIENTE).
// Van ANTES de /clientes/:id para no colisionar con el parámetro.
apiRouter.post('/clientes/progreso/iniciar', iniciarProgreso)
apiRouter.patch('/clientes/progreso/:id', actualizarProgreso)
apiRouter.post('/clientes/progreso/:id/completar', completarProgreso)
// Liga remota: endpoints públicos con token propio (scope 'firma-remota'); el
// scope se verifica dentro de cada controlador. Van ANTES de /clientes/:id.
apiRouter.get('/clientes/publico/:token', limitadorLiga, obtenerClientePublico)

View File

@@ -449,6 +449,39 @@ export class MondayService {
return this.crearItem(boardId, datos.nombre, columnValues)
}
/**
* Actualiza un Contacto existente con los campos presentes (mismo builder de
* valores que crearContacto) — usado por el guardado progresivo del alta.
* El nombre del item se actualiza por el column id especial 'name'.
*/
async actualizarContacto(itemId: string, datos: Partial<DatosContacto>): Promise<void> {
const boardId = config.monday.boards.contactos
const valores = JSON.parse(
await this.construirColumnValues(boardId, {
Teléfono: datos.telefono,
Email: datos.email,
CURP: datos.curp,
RFC: datos.rfc,
Dirección: datos.direccion,
Estado: datos.estadoLabel,
Sexo: datos.sexoLabel,
'Estado Civil': datos.estadoCivilLabel,
'Rango de Edad': datos.rangoEdadLabel,
'Actividad Economica': datos.actividadEconomicaLabel,
'Vivienda Actual': datos.viviendaActualLabel,
'Proposito de Compra': datos.propositoCompraLabel,
'Fecha Nacimiento': datos.fechaNacimiento,
}),
) as Record<string, unknown>
if (datos.nombre !== undefined && datos.nombre !== '') valores['name'] = datos.nombre
await this.graphql(
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
}`,
{ boardId, itemId, columnValues: JSON.stringify(valores) },
)
}
async crearApartado(datos: DatosApartado): Promise<string> {
const boardId = config.monday.boards.apartado
const columnValues = await this.construirColumnValues(boardId, {