feat(alta): borrador, pre-llenado desde cotización y liga remota de firma

- Borradores por vendedor (guardar/continuar/eliminar), se limpia al registrar
- Pre-llenado con ?cotizacion=<id> (cliente, contacto, proyecto, montos);
  botón 'Iniciar alta' en Mis cotizaciones del cotizador
- Liga remota pública /client/firma/<token> (JWT scope firma-remota, 72h,
  rate limit, solo datos mínimos sin PLD): firma digital + subida de
  documentos que prende checkboxes en Monday Apartado
- Tokens de liga no sirven como sesión y viceversa
- Tests: api 228, alta 82, cotizador 82
This commit is contained in:
2026-08-28 07:29:22 +00:00
parent 4f31d1c171
commit 59944668a8
18 changed files with 1589 additions and 33 deletions

View File

@@ -73,6 +73,21 @@ model Documento {
@@index([clienteId])
}
// Borrador del wizard de alta de clientes: el asesor guarda el avance y lo
// retoma después. `datosJson` es el estado serializado del wizard (sin los
// File de documentos; solo se persiste lo que cabe en JSON).
model BorradorAlta {
id String @id @default(uuid())
usuarioId String // sub del JWT del asesor dueño del borrador
nombre String // etiqueta visible: nombre del cliente o "Sin nombre"
datosJson String
paso Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([usuarioId])
}
model PlanPago {
id String @id @default(uuid())
ventaRef String?

View File

@@ -66,6 +66,11 @@ export const config = {
// Password del usuario admin sembrado (seed-usuarios.ts)
adminPassword: env('ADMIN_PASSWORD', 'Frasa2026'),
},
ligaRemota: {
// Expiración del token de la liga pública de firma/documentos (cadena
// aceptada por jsonwebtoken, p. ej. '72h')
expiresIn: env('LIGA_REMOTA_EXP', '72h'),
},
} as const
export type AppConfig = typeof config

View File

@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import {
eliminarBorrador,
guardarBorrador,
listarBorradores,
obtenerBorrador,
} from './borradores.controller.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
const prismaMock = vi.hoisted(() => ({
borradorAlta: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
function reqFake(overrides: Partial<Request> = {}): Request {
return {
params: {},
body: {},
usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' },
...overrides,
} as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const status = vi.fn().mockReturnValue({ json, end })
return { res: { json, status } as unknown as Response, status, json, end }
}
const borradorFake = (overrides: Record<string, unknown> = {}) => ({
id: 'b-1',
usuarioId: 'u-1',
nombre: 'Sin nombre',
datosJson: '{"datosGenerales":{}}',
paso: 0,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('listarBorradores', () => {
it('devuelve solo los del usuario del token y SIN datosJson', async () => {
prismaMock.borradorAlta.findMany.mockResolvedValue([{ id: 'b-1', nombre: 'X' }])
const { res, json } = resFake()
await listarBorradores(reqFake(), res)
const args = prismaMock.borradorAlta.findMany.mock.calls[0]![0]
expect(args.where).toEqual({ usuarioId: 'u-1' })
expect(args.select).not.toHaveProperty('datosJson')
expect(json).toHaveBeenCalledWith([{ id: 'b-1', nombre: 'X' }])
})
})
describe('guardarBorrador', () => {
it('crea un borrador nuevo (201) con etiqueta del nombre del cliente', async () => {
prismaMock.borradorAlta.create.mockResolvedValue(borradorFake())
const { res, status } = resFake()
const datos = { datosGenerales: { nombreCompleto: 'JUAN PEREZ' } }
await guardarBorrador(reqFake({ body: { paso: 2, datos } }), res)
const creado = prismaMock.borradorAlta.create.mock.calls[0]![0].data
expect(creado.usuarioId).toBe('u-1')
expect(creado.nombre).toBe('JUAN PEREZ')
expect(creado.paso).toBe(2)
expect(JSON.parse(creado.datosJson)).toEqual(datos)
expect(status).toHaveBeenCalledWith(201)
})
it('usa "Sin nombre" cuando el cliente aún no tiene nombre', async () => {
prismaMock.borradorAlta.create.mockResolvedValue(borradorFake())
const { res } = resFake()
await guardarBorrador(reqFake({ body: { paso: 0, datos: {} } }), res)
expect(prismaMock.borradorAlta.create.mock.calls[0]![0].data.nombre).toBe('Sin nombre')
})
it('con id actualiza el borrador propio (upsert)', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(borradorFake())
prismaMock.borradorAlta.update.mockResolvedValue(borradorFake({ paso: 5 }))
const { res, json } = resFake()
await guardarBorrador(reqFake({ body: { id: 'b-1', paso: 5, datos: {} } }), res)
expect(prismaMock.borradorAlta.update).toHaveBeenCalledWith({
where: { id: 'b-1' },
data: expect.objectContaining({ paso: 5 }),
})
expect(prismaMock.borradorAlta.create).not.toHaveBeenCalled()
expect(json).toHaveBeenCalled()
})
it('rechaza actualizar un borrador de otro usuario (403)', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(borradorFake({ usuarioId: 'otro' }))
await expect(
guardarBorrador(reqFake({ body: { id: 'b-1', paso: 1, datos: {} } }), resFake().res),
).rejects.toMatchObject({ status: 403 })
expect(prismaMock.borradorAlta.update).not.toHaveBeenCalled()
})
it('un admin sí puede actualizar el borrador de otro usuario', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(borradorFake({ usuarioId: 'otro' }))
prismaMock.borradorAlta.update.mockResolvedValue(borradorFake())
const req = reqFake({
body: { id: 'b-1', paso: 1, datos: {} },
usuarioToken: { sub: 'admin-1', username: 'admin', rol: 'admin' },
})
await guardarBorrador(req, resFake().res)
expect(prismaMock.borradorAlta.update).toHaveBeenCalled()
})
it('404 si el id a actualizar no existe', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(null)
await expect(
guardarBorrador(reqFake({ body: { id: 'b-x', paso: 1, datos: {} } }), resFake().res),
).rejects.toMatchObject({ status: 404 })
})
})
describe('obtenerBorrador', () => {
it('devuelve el borrador completo al dueño', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(borradorFake())
const { res, json } = resFake()
await obtenerBorrador(reqFake({ params: { id: 'b-1' } }), res)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ id: 'b-1', datosJson: expect.any(String) }))
})
it('403 al abrir el borrador de otro usuario', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(borradorFake({ usuarioId: 'otro' }))
await expect(obtenerBorrador(reqFake({ params: { id: 'b-1' } }), resFake().res)).rejects.toMatchObject({
status: 403,
})
})
})
describe('eliminarBorrador', () => {
it('borra el borrador propio (204)', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(borradorFake())
prismaMock.borradorAlta.delete.mockResolvedValue(borradorFake())
const { res, status, end } = resFake()
await eliminarBorrador(reqFake({ params: { id: 'b-1' } }), res)
expect(prismaMock.borradorAlta.delete).toHaveBeenCalledWith({ where: { id: 'b-1' } })
expect(status).toHaveBeenCalledWith(204)
expect(end).toHaveBeenCalled()
})
it('no borra el borrador de otro usuario (403)', async () => {
prismaMock.borradorAlta.findUnique.mockResolvedValue(borradorFake({ usuarioId: 'otro' }))
await expect(eliminarBorrador(reqFake({ params: { id: 'b-1' } }), resFake().res)).rejects.toMatchObject({
status: 403,
})
expect(prismaMock.borradorAlta.delete).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,81 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import { AppError, NotFoundError } from '../utils/errors.js'
// ---------- Borradores del alta de clientes ----------
const guardarBorradorSchema = z.object({
id: z.string().min(1).optional(),
nombre: z.string().max(200).optional(),
paso: z.number().int().min(0).max(50).default(0),
// Estado del wizard serializado (objeto JSON cualquiera; sin Files)
datos: z.unknown(),
})
/** sub del JWT; con AUTH_ENABLED=false (tests) cae a un id fijo */
const usuarioDe = (req: Request): string => req.usuarioToken?.sub ?? 'anon'
const esAdmin = (req: Request): boolean => req.usuarioToken?.rol === 'admin'
/** 404 si no existe, 403 si es de otro usuario (salvo admin) */
async function borradorPropio(req: Request, id: string) {
const borrador = await prisma.borradorAlta.findUnique({ where: { id } })
if (!borrador) throw new NotFoundError('Borrador no encontrado')
if (borrador.usuarioId !== usuarioDe(req) && !esAdmin(req)) {
throw new AppError(403, 'El borrador pertenece a otro usuario')
}
return borrador
}
// Etiqueta visible: nombre del cliente dentro del estado, o "Sin nombre"
function etiquetaDe(datos: unknown, nombreExplicito: string | undefined): string {
if (nombreExplicito?.trim()) return nombreExplicito.trim()
const dg = (datos as { datosGenerales?: { nombreCompleto?: unknown } } | null)?.datosGenerales
const nombre = typeof dg?.nombreCompleto === 'string' ? dg.nombreCompleto.trim() : ''
return nombre || 'Sin nombre'
}
/** GET /api/borradores — lista los míos, SIN datosJson (metadatos) */
export async function listarBorradores(req: Request, res: Response): Promise<void> {
const borradores = await prisma.borradorAlta.findMany({
where: { usuarioId: usuarioDe(req) },
select: { id: true, nombre: true, paso: true, createdAt: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
})
res.json(borradores)
}
/** POST /api/borradores — upsert: con `id` actualiza (si es mío), sin `id` crea */
export async function guardarBorrador(req: Request, res: Response): Promise<void> {
const body = guardarBorradorSchema.parse(req.body)
const datosJson = JSON.stringify(body.datos ?? null)
const nombre = etiquetaDe(body.datos, body.nombre)
if (body.id) {
await borradorPropio(req, body.id)
const actualizado = await prisma.borradorAlta.update({
where: { id: body.id },
data: { nombre, datosJson, paso: body.paso },
})
res.json(actualizado)
return
}
const creado = await prisma.borradorAlta.create({
data: { usuarioId: usuarioDe(req), nombre, datosJson, paso: body.paso },
})
res.status(201).json(creado)
}
/** GET /api/borradores/:id — borrador completo (solo el dueño o admin) */
export async function obtenerBorrador(req: Request, res: Response): Promise<void> {
const borrador = await borradorPropio(req, String(req.params['id']))
res.json(borrador)
}
/** DELETE /api/borradores/:id — solo el dueño o admin */
export async function eliminarBorrador(req: Request, res: Response): Promise<void> {
const borrador = await borradorPropio(req, String(req.params['id']))
await prisma.borradorAlta.delete({ where: { id: borrador.id } })
res.status(204).end()
}

View File

@@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import jwt from 'jsonwebtoken'
import {
firmarRemoto,
generarLiga,
generarLigaToken,
obtenerClientePublico,
subirDocumentosPublico,
verificarLigaToken,
} from './liga.controller.js'
// ---------- Prisma y Monday mockeados (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
cliente: {
findUnique: vi.fn(),
update: vi.fn(),
},
documento: {
findMany: vi.fn(),
create: vi.fn(),
},
}))
const mondayMock = vi.hoisted(() => ({
cambiarValorColumna: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
const SECRET = 'test-jwt-secret' // JWT_SECRET fijado en vitest.config.ts
function reqFake(overrides: Partial<Request> = {}): Request {
return { params: {}, body: {}, ip: '127.0.0.1', ...overrides } as Request
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
const clienteFake = (overrides: Record<string, unknown> = {}) => ({
id: 'c-1',
nombreCompleto: 'CLIENTE DEMO',
curp: 'CURPSECRETA123',
rfc: 'RFCSECRETO1',
email: 'demo@example.com',
celular: '5551234567',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
payloadJson: '{"datosGenerales":{"direccion":"CALLE SECRETA 1"}}',
firmaPng: null,
mondayContactoId: null,
mondayApartadoId: null,
createdAt: new Date(),
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('token de liga (scope firma-remota)', () => {
it('ida y vuelta: scope, clienteId y expiración de 72h por defecto', () => {
const token = generarLigaToken('c-1')
const payload = verificarLigaToken(token)
expect(payload.scope).toBe('firma-remota')
expect(payload.clienteId).toBe('c-1')
const { exp, iat } = jwt.decode(token) as { exp: number; iat: number }
expect(exp - iat).toBe(72 * 3600)
})
it('rechaza un token de login (sin scope firma-remota)', () => {
const tokenLogin = jwt.sign({ sub: 'u-1', username: 'asesor', rol: 'vendedor' }, SECRET)
expect(() => verificarLigaToken(tokenLogin)).toThrow('Liga inválida o expirada')
})
it('rechaza un token expirado', () => {
const expirado = jwt.sign({ scope: 'firma-remota', clienteId: 'c-1' }, SECRET, { expiresIn: '-1s' })
expect(() => verificarLigaToken(expirado)).toThrow('Liga inválida o expirada')
})
it('rechaza basura y tokens de liga sin clienteId', () => {
expect(() => verificarLigaToken('no-es-un-jwt')).toThrow('Liga inválida o expirada')
const sinCliente = jwt.sign({ scope: 'firma-remota' }, SECRET)
expect(() => verificarLigaToken(sinCliente)).toThrow('Liga inválida o expirada')
})
})
describe('generarLiga (POST /clientes/:id/liga)', () => {
it('devuelve token, url pública y expiración', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
const { res, status, json } = resFake()
await generarLiga(reqFake({ params: { id: 'c-1' } }), res)
expect(status).toHaveBeenCalledWith(201)
const cuerpo = json.mock.calls[0]![0]
expect(cuerpo.url).toBe(`/client/firma/${cuerpo.token}`)
expect(verificarLigaToken(cuerpo.token).clienteId).toBe('c-1')
expect(new Date(cuerpo.expiraEn).getTime()).toBeGreaterThan(Date.now())
})
it('404 si el cliente no existe', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(null)
await expect(generarLiga(reqFake({ params: { id: 'x' } }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
})
describe('obtenerClientePublico (GET /clientes/publico/:token)', () => {
it('expone SOLO nombre, proyecto, unidad y estado de firma/documentos (nada de PLD)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.findMany.mockResolvedValue([
{ tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: new Date() },
])
const { res, json } = resFake()
const token = generarLigaToken('c-1')
await obtenerClientePublico(reqFake({ params: { token } }), res)
const cuerpo = json.mock.calls[0]![0]
expect(cuerpo).toEqual({
nombre: 'CLIENTE DEMO',
proyecto: 'DIAGONAL',
unidad: 'U-101',
firma: false,
documentos: [{ tipo: 'INE', nombreArchivo: 'ine.pdf', createdAt: expect.any(Date) }],
})
// Guardas explícitas anti-filtrado PLD
const crudo = JSON.stringify(cuerpo)
expect(crudo).not.toContain('CURPSECRETA123')
expect(crudo).not.toContain('RFCSECRETO1')
expect(crudo).not.toContain('CALLE SECRETA')
expect(cuerpo).not.toHaveProperty('payloadJson')
expect(cuerpo).not.toHaveProperty('curp')
expect(cuerpo).not.toHaveProperty('rfc')
expect(cuerpo).not.toHaveProperty('email')
expect(cuerpo).not.toHaveProperty('celular')
})
it('no acepta un token de login aunque sea JWT válido (401)', async () => {
const tokenLogin = jwt.sign({ sub: 'u-1', username: 'asesor', rol: 'vendedor' }, SECRET)
await expect(
obtenerClientePublico(reqFake({ params: { token: tokenLogin } }), resFake().res),
).rejects.toMatchObject({ status: 401 })
expect(prismaMock.cliente.findUnique).not.toHaveBeenCalled()
})
})
describe('firmarRemoto (POST /clientes/publico/:token/firma)', () => {
const firmaPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAA='
it('guarda la firma en Cliente.firmaPng', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.cliente.update.mockResolvedValue(clienteFake({ firmaPng }))
const { res, json } = resFake()
const token = generarLigaToken('c-1')
await firmarRemoto(reqFake({ params: { token }, body: { firmaDataUrl: firmaPng } }), res)
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
where: { id: 'c-1' },
data: { firmaPng },
})
expect(json).toHaveBeenCalledWith({ ok: true, firma: true })
})
it('rechaza una firma que no sea PNG data URL', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
const token = generarLigaToken('c-1')
await expect(
firmarRemoto(reqFake({ params: { token }, body: { firmaDataUrl: 'data:text/html;base64,PHNjcmlwdA==' } }), resFake().res),
).rejects.toThrow()
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
})
})
describe('subirDocumentosPublico (POST /clientes/publico/:token/documentos)', () => {
const archivoFake = {
originalname: 'curp.pdf',
path: '/tmp/uploads/c-1/CURP_abc.pdf',
mimetype: 'application/pdf',
size: 1234,
} as Express.Multer.File
it('rechaza tipos fuera de la whitelist (400)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
const token = generarLigaToken('c-1')
await expect(
subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'CONTRATO' }, files: [archivoFake] }),
resFake().res,
),
).rejects.toMatchObject({ status: 400 })
expect(prismaMock.documento.create).not.toHaveBeenCalled()
})
it('acepta un tipo de la whitelist y persiste el documento (201)', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
const { res, status } = resFake()
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'curp' }, files: [archivoFake] }),
res,
)
expect(prismaMock.documento.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clienteId: 'c-1', tipo: 'CURP', mime: 'application/pdf' }),
})
expect(status).toHaveBeenCalledWith(201)
})
it('prende el checkbox en Apartado cuando el cliente tiene mondayApartadoId', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake({ mondayApartadoId: 'ap-9' }))
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
mondayMock.cambiarValorColumna.mockResolvedValue(undefined)
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'CURP' }, files: [archivoFake] }),
resFake().res,
)
expect(mondayMock.cambiarValorColumna).toHaveBeenCalledWith(expect.any(String), 'ap-9', 'CURP', true)
})
it('no llama a Monday sin mondayApartadoId', async () => {
prismaMock.cliente.findUnique.mockResolvedValue(clienteFake())
prismaMock.documento.create.mockResolvedValue({ id: 'd-1' })
const token = generarLigaToken('c-1')
await subirDocumentosPublico(
reqFake({ params: { token }, body: { tipo: 'INE' }, files: [archivoFake] }),
resFake().res,
)
expect(mondayMock.cambiarValorColumna).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,234 @@
import type { NextFunction, Request, Response } from 'express'
import path from 'node:path'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import jwt from 'jsonwebtoken'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService } from '../services/monday.service.js'
import { prisma } from '../services/prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import { checkboxParaTipoDocumento } from './clientes.controller.js'
// ---------- Liga remota de firma/documentos (modo público con token) ----------
// Token JWT dedicado: scope 'firma-remota' + clienteId. Un token de login NO
// sirve aquí (no trae scope) y un token de liga NO sirve como sesión (el
// middleware de auth exige sub/username/rol para las rutas internas).
export interface LigaTokenPayload {
scope: 'firma-remota'
clienteId: string
}
export function generarLigaToken(clienteId: string): string {
return jwt.sign({ scope: 'firma-remota', clienteId }, config.auth.jwtSecret, {
expiresIn: config.ligaRemota.expiresIn,
} as jwt.SignOptions)
}
/** Verifica el token de la liga; lanza 401 si es inválido, expiró o no es de firma remota */
export function verificarLigaToken(token: string): LigaTokenPayload {
let payload: unknown
try {
payload = jwt.verify(token, config.auth.jwtSecret)
} catch {
throw new AppError(401, 'Liga inválida o expirada')
}
const p = payload as Partial<LigaTokenPayload>
if (p.scope !== 'firma-remota' || typeof p.clienteId !== 'string' || !p.clienteId) {
throw new AppError(401, 'Liga inválida o expirada')
}
return p as LigaTokenPayload
}
// ---------- Rate limit básico en memoria para los endpoints públicos ----------
const VENTANA_MS = 5 * 60 * 1000
const MAX_POR_VENTANA = 30
const contadores = new Map<string, { conteo: number; reinicio: number }>()
export function limitadorLiga(req: Request, res: Response, next: NextFunction): void {
const ahora = Date.now()
const clave = req.ip ?? 'desconocido'
const entrada = contadores.get(clave)
if (!entrada || entrada.reinicio <= ahora) {
contadores.set(clave, { conteo: 1, reinicio: ahora + VENTANA_MS })
} else {
entrada.conteo++
if (entrada.conteo > MAX_POR_VENTANA) {
// eslint-disable-next-line no-console
console.warn(`[liga] rate limit excedido para ${clave} en ${req.path}`)
res.status(429).json({ error: 'Demasiadas solicitudes, intenta más tarde' })
return
}
}
next()
}
// ---------- Endpoints ----------
/** POST /api/clientes/:id/liga (auth) — genera la liga pública de firma remota */
export async function generarLiga(req: Request, res: Response): Promise<void> {
const cliente = await prisma.cliente.findUnique({ where: { id: String(req.params['id']) } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const token = generarLigaToken(cliente.id)
const { exp } = jwt.decode(token) as { exp: number }
res.status(201).json({
token,
url: `/client/firma/${token}`,
expiraEn: new Date(exp * 1000).toISOString(),
})
}
const tokenDeParams = (req: Request): LigaTokenPayload => verificarLigaToken(String(req.params['token']))
const clienteDeLiga = async (payload: LigaTokenPayload) => {
const cliente = await prisma.cliente.findUnique({ where: { id: payload.clienteId } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
return cliente
}
/**
* GET /api/clientes/publico/:token — PÚBLICO. Devuelve SOLO lo mínimo para la
* pantalla de firma: nombre, proyecto, unidad y estado de firma/documentos.
* NUNCA datos PLD (CURP/RFC/domicilio/payload completo).
*/
export async function obtenerClientePublico(req: Request, res: Response): Promise<void> {
const cliente = await clienteDeLiga(tokenDeParams(req))
const documentos = await prisma.documento.findMany({
where: { clienteId: cliente.id },
select: { tipo: true, nombreArchivo: true, createdAt: true },
orderBy: { createdAt: 'desc' },
})
res.json({
nombre: cliente.nombreCompleto,
proyecto: cliente.proyecto,
unidad: cliente.unidadCodigo,
firma: cliente.firmaPng !== null,
documentos,
})
}
const firmaRemotaSchema = z.object({
// PNG base64 en data URL; ~3 MB de techo (una firma típica pesa < 200 KB)
firmaDataUrl: z
.string()
.max(4 * 1024 * 1024)
.regex(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/, 'La firma debe ser un PNG en data URL'),
})
/** POST /api/clientes/publico/:token/firma — PÚBLICO. Guarda la firma del cliente */
export async function firmarRemoto(req: Request, res: Response): Promise<void> {
const cliente = await clienteDeLiga(tokenDeParams(req))
const body = firmaRemotaSchema.parse(req.body)
await prisma.cliente.update({
where: { id: cliente.id },
data: { firmaPng: body.firmaDataUrl },
})
res.json({ ok: true, firma: true })
}
// Tipos de documento admitidos por la liga pública (mismos slots del expediente)
const TIPOS_DOCUMENTO_REMOTO = new Set([
'INE',
'CURP',
'CONSTANCIA_RFC',
'COMPROBANTE_DOMICILIO',
'ACTA_NACIMIENTO',
])
const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
const EXT_POR_MIME: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
}
// Multer del endpoint público: la carpeta se deriva del clienteId del token
const storagePublico = multer.diskStorage({
destination: (req, _file, cb) => {
try {
const { clienteId } = tokenDeParams(req)
const dir = path.join(config.uploadsDir, clienteId)
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
} catch (err) {
cb(err as Error, '')
}
},
filename: (req, file, cb) => {
const tipo = String(req.body['tipo'] ?? '').toUpperCase()
const ext = EXT_POR_MIME[file.mimetype] ?? 'bin'
cb(null, `${tipo}_${randomUUID()}.${ext}`)
},
})
export const uploadDocumentosPublico = multer({
storage: storagePublico,
limits: { fileSize: 10 * 1024 * 1024, files: 10 },
fileFilter: (req, file, cb) => {
// Validar aquí el tipo (además del controlador) evita archivos huérfanos
// en disco cuando el tipo no es de la whitelist.
const tipo = String(req.body['tipo'] ?? '').toUpperCase()
if (!TIPOS_DOCUMENTO_REMOTO.has(tipo)) {
cb(new BadRequestError(`Tipo de documento no permitido: ${tipo || '(vacío)'}`))
return
}
if (!MIME_PERMITIDOS.has(file.mimetype)) {
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`))
return
}
cb(null, true)
},
})
/**
* POST /api/clientes/publico/:token/documentos — PÚBLICO. Adjunta documentos
* del expediente (multipart, campo `archivos`, `tipo` whitelist). Si el cliente
* tiene item en el tablero Apartado, prende el checkbox correspondiente.
*/
export async function subirDocumentosPublico(req: Request, res: Response): Promise<void> {
const cliente = await clienteDeLiga(tokenDeParams(req))
const tipo = String(req.body['tipo'] ?? '').toUpperCase()
if (!TIPOS_DOCUMENTO_REMOTO.has(tipo)) {
throw new BadRequestError(`Tipo de documento no permitido: ${tipo || '(vacío)'}`)
}
const archivos = (req.files ?? []) as Express.Multer.File[]
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,
},
}),
),
)
// Mismo efecto que la subida interna: checkbox del documento en Apartado.
// Un fallo de Monday no rompe la subida.
const checkbox = checkboxParaTipoDocumento(tipo)
if (checkbox !== null && cliente.mondayApartadoId !== null) {
try {
await mondayService.cambiarValorColumna(
config.monday.boards.apartado,
cliente.mondayApartadoId,
checkbox,
true,
)
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
}
}
res.status(201).json(documentos)
}

View File

@@ -80,6 +80,36 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
expect(next).toHaveBeenCalledOnce()
})
it('deja pasar los endpoints públicos de la liga de firma remota (el scope se verifica en el controlador)', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/clientes/publico/token-abc' },
{ method: 'POST', path: '/clientes/publico/token-abc/firma' },
{ method: 'POST', path: '/clientes/publico/token-abc/documentos' },
]
for (const c of casos) {
vi.clearAllMocks()
authMiddleware(reqFake(c), resFake().res, next)
expect(next).toHaveBeenCalledOnce()
}
})
it('el patrón público de la liga no abre otras rutas de clientes', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/clientes/abc-123' },
{ method: 'POST', path: '/clientes/abc-123/liga' },
{ method: 'POST', path: '/clientes/publico/token-abc' },
{ method: 'GET', path: '/clientes/publico/token-abc/firma' },
{ method: 'DELETE', path: '/clientes/publico/token-abc' },
{ method: 'GET', path: '/clientes/publico/token-abc/extra' },
{ method: 'GET', path: '/clientes/publico' },
]
for (const c of casos) {
const { res, status } = resFake()
authMiddleware(reqFake(c), res, next)
expect(status).toHaveBeenCalledWith(401)
}
})
it('el patrón público no abre otras rutas de cotizaciones', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/cotizaciones/abc-123' },

View File

@@ -17,6 +17,11 @@ const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [
// final lo abre sin sesión; devuelve solo datos comerciales, nunca del cliente.
const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
{ metodo: 'GET', patron: /^\/cotizaciones\/[^/]+\/publico$/ },
// Liga remota de firma: públicos pero con token propio (scope 'firma-remota')
// que se verifica dentro del controlador; no aceptan tokens de login.
{ metodo: 'GET', patron: /^\/clientes\/publico\/[^/]+$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/firma$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/documentos$/ },
]
declare module 'express-serve-static-core' {

View File

@@ -16,6 +16,20 @@ import {
subirDocumentos,
uploadDocumentos,
} from '../controllers/clientes.controller.js'
import {
eliminarBorrador,
guardarBorrador,
listarBorradores,
obtenerBorrador,
} from '../controllers/borradores.controller.js'
import {
firmarRemoto,
generarLiga,
limitadorLiga,
obtenerClientePublico,
subirDocumentosPublico,
uploadDocumentosPublico,
} from '../controllers/liga.controller.js'
import {
agregarNota,
cancelarPlan,
@@ -69,10 +83,26 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente)
// 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)
apiRouter.post('/clientes/publico/:token/firma', limitadorLiga, firmarRemoto)
apiRouter.post(
'/clientes/publico/:token/documentos',
limitadorLiga,
uploadDocumentosPublico.array('archivos', 10),
subirDocumentosPublico,
)
apiRouter.post('/clientes/:id/liga', generarLiga)
apiRouter.get('/clientes/:id', obtenerCliente)
apiRouter.post('/clientes/:id/documentos', uploadDocumentos.array('archivos', 10), subirDocumentos)
apiRouter.get('/clientes/:id/documentos', listarDocumentos)
apiRouter.get('/borradores', listarBorradores)
apiRouter.post('/borradores', guardarBorrador)
apiRouter.get('/borradores/:id', obtenerBorrador)
apiRouter.delete('/borradores/:id', eliminarBorrador)
apiRouter.post('/planes', crearPlan)
apiRouter.get('/planes', listarPlanes)
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)

View File

@@ -66,7 +66,10 @@ export interface TokenPayload {
/** Verifica un Bearer token y devuelve su payload; lanza 401 si no es válido */
export function verificarToken(token: string): TokenPayload {
try {
return jwt.verify(token, config.auth.jwtSecret) as TokenPayload
const payload = jwt.verify(token, config.auth.jwtSecret) as Partial<TokenPayload>
// Los tokens con otro scope (p. ej. la liga de firma remota) no son sesión
if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('sin sub')
return payload as TokenPayload
} catch {
throw new AppError(401, 'Token inválido o expirado')
}