From 59944668a88ab0fd5a2dc23614f5a26c24232805 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 28 Aug 2026 07:29:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(alta):=20borrador,=20pre-llenado=20desde?= =?UTF-8?q?=20cotizaci=C3=B3n=20y=20liga=20remota=20de=20firma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Borradores por vendedor (guardar/continuar/eliminar), se limpia al registrar - Pre-llenado con ?cotizacion= (cliente, contacto, proyecto, montos); botón 'Iniciar alta' en Mis cotizaciones del cotizador - Liga remota pública /client/firma/ (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 --- api-gateway/prisma/schema.prisma | 15 + api-gateway/src/config/index.ts | 5 + .../controllers/borradores.controller.test.ts | 180 +++++++++++ .../src/controllers/borradores.controller.ts | 81 +++++ .../src/controllers/liga.controller.test.ts | 249 +++++++++++++++ .../src/controllers/liga.controller.ts | 234 ++++++++++++++ .../src/middleware/auth.middleware.test.ts | 30 ++ api-gateway/src/middleware/auth.middleware.ts | 5 + api-gateway/src/routes/index.ts | 30 ++ api-gateway/src/services/auth.service.ts | 5 +- .../alta-clientes/src/hooks/useAltaCliente.ts | 14 + .../src/lib/cotizacionAlta.test.ts | 54 ++++ apps/alta-clientes/src/lib/cotizacionAlta.ts | 44 +++ apps/alta-clientes/src/main.tsx | 19 +- .../src/pages/AltaClientePage.tsx | 298 ++++++++++++++++-- .../src/pages/FirmaRemotaPage.tsx | 197 ++++++++++++ apps/alta-clientes/src/services/api.ts | 140 ++++++++ .../src/components/MisCotizaciones.tsx | 22 +- 18 files changed, 1589 insertions(+), 33 deletions(-) create mode 100644 api-gateway/src/controllers/borradores.controller.test.ts create mode 100644 api-gateway/src/controllers/borradores.controller.ts create mode 100644 api-gateway/src/controllers/liga.controller.test.ts create mode 100644 api-gateway/src/controllers/liga.controller.ts create mode 100644 apps/alta-clientes/src/lib/cotizacionAlta.test.ts create mode 100644 apps/alta-clientes/src/lib/cotizacionAlta.ts create mode 100644 apps/alta-clientes/src/pages/FirmaRemotaPage.tsx diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 87bc0f2..d8a4617 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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? diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index effc270..73e09da 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -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 diff --git a/api-gateway/src/controllers/borradores.controller.test.ts b/api-gateway/src/controllers/borradores.controller.test.ts new file mode 100644 index 0000000..223e78d --- /dev/null +++ b/api-gateway/src/controllers/borradores.controller.test.ts @@ -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 { + 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 = {}) => ({ + 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() + }) +}) diff --git a/api-gateway/src/controllers/borradores.controller.ts b/api-gateway/src/controllers/borradores.controller.ts new file mode 100644 index 0000000..c2c0904 --- /dev/null +++ b/api-gateway/src/controllers/borradores.controller.ts @@ -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 { + 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 { + 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 { + 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 { + const borrador = await borradorPropio(req, String(req.params['id'])) + await prisma.borradorAlta.delete({ where: { id: borrador.id } }) + res.status(204).end() +} diff --git a/api-gateway/src/controllers/liga.controller.test.ts b/api-gateway/src/controllers/liga.controller.test.ts new file mode 100644 index 0000000..d23c7e6 --- /dev/null +++ b/api-gateway/src/controllers/liga.controller.test.ts @@ -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 { + 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 = {}) => ({ + 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() + }) +}) diff --git a/api-gateway/src/controllers/liga.controller.ts b/api-gateway/src/controllers/liga.controller.ts new file mode 100644 index 0000000..7a48487 --- /dev/null +++ b/api-gateway/src/controllers/liga.controller.ts @@ -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 + 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() + +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 { + 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 { + 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 { + 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 = { + '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 { + 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) +} diff --git a/api-gateway/src/middleware/auth.middleware.test.ts b/api-gateway/src/middleware/auth.middleware.test.ts index 24fa02c..620e8e8 100644 --- a/api-gateway/src/middleware/auth.middleware.test.ts +++ b/api-gateway/src/middleware/auth.middleware.test.ts @@ -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[] = [ + { 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[] = [ + { 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[] = [ { method: 'GET', path: '/cotizaciones/abc-123' }, diff --git a/api-gateway/src/middleware/auth.middleware.ts b/api-gateway/src/middleware/auth.middleware.ts index 34a0ec3..001dc7a 100644 --- a/api-gateway/src/middleware/auth.middleware.ts +++ b/api-gateway/src/middleware/auth.middleware.ts @@ -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' { diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index eb3dca2..6c7327a 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -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) diff --git a/api-gateway/src/services/auth.service.ts b/api-gateway/src/services/auth.service.ts index fb5752b..87d4971 100644 --- a/api-gateway/src/services/auth.service.ts +++ b/api-gateway/src/services/auth.service.ts @@ -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 + // 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') } diff --git a/apps/alta-clientes/src/hooks/useAltaCliente.ts b/apps/alta-clientes/src/hooks/useAltaCliente.ts index 190413f..95244bb 100644 --- a/apps/alta-clientes/src/hooks/useAltaCliente.ts +++ b/apps/alta-clientes/src/hooks/useAltaCliente.ts @@ -134,6 +134,18 @@ export function useAltaCliente() { const anterior = () => setPaso((p) => Math.max(p - 1, 0)) const irAPaso = (p: number) => setPaso(p) + /** Restaura el estado completo (p. ej. desde un borrador) y salta a un paso */ + const restaurar = (nuevosDatos: ReturnType, pasoDestino = 0) => { + setDatos(nuevosDatos) + setPaso(pasoDestino) + } + + /** Vuelve al estado inicial (alta nueva) */ + const reiniciar = () => { + setDatos(estadoInicial()) + setPaso(0) + } + return { paso, siguiente, @@ -142,6 +154,8 @@ export function useAltaCliente() { datos, alta, actualizar, + restaurar, + reiniciar, agregarDocumento, quitarDocumento, } diff --git a/apps/alta-clientes/src/lib/cotizacionAlta.test.ts b/apps/alta-clientes/src/lib/cotizacionAlta.test.ts new file mode 100644 index 0000000..91bcdb6 --- /dev/null +++ b/apps/alta-clientes/src/lib/cotizacionAlta.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { cotizacionAPrellenado, type CotizacionParaAlta } from './cotizacionAlta' +import { PROYECTOS } from '../data/constantes' + +const base: CotizacionParaAlta = { + clienteNombre: 'María Elena López García', + clienteEmail: 'maria@example.com', + clienteTelefono: '4421234567', + desarrollo: 'DIAGONAL', + unidadCodigo: 'U-101', + precioFinal: 1450000, + enganche: 217500, +} + +describe('cotizacionAPrellenado', () => { + it('prellena nombre y contacto del cliente', () => { + const pre = cotizacionAPrellenado(base) + expect(pre.datosGenerales.nombreCompleto).toBe('MARÍA ELENA LÓPEZ GARCÍA') + expect(pre.datosGenerales.email).toBe('maria@example.com') + expect(pre.datosGenerales.celular).toBe('4421234567') + }) + + it('omite email/celular cuando la cotización no los trae', () => { + const pre = cotizacionAPrellenado({ ...base, clienteEmail: null, clienteTelefono: null }) + expect(pre.datosGenerales).not.toHaveProperty('email') + expect(pre.datosGenerales).not.toHaveProperty('celular') + }) + + it('mapea un desarrollo conocido al dropdown de proyecto', () => { + const pre = cotizacionAPrellenado(base) + expect(PROYECTOS).toContain('DIAGONAL') + expect(pre.viviendaCredito.proyecto).toBe('DIAGONAL') + expect(pre.viviendaCredito.proyectoOtro).toBe('') + }) + + it('un desarrollo fuera de PROYECTOS cae en OTRO con el nombre en texto libre', () => { + const pre = cotizacionAPrellenado({ ...base, desarrollo: 'JURIQUILLA' }) + expect(pre.viviendaCredito.proyecto).toBe('OTRO') + expect(pre.viviendaCredito.proyectoOtro).toBe('JURIQUILLA') + }) + + it('prellena unidad, valor de venta y enganche', () => { + const pre = cotizacionAPrellenado(base) + expect(pre.viviendaCredito.ubicacion).toBe('U-101') + expect(pre.viviendaCredito.valorVenta).toBe('1450000') + expect(pre.viviendaCredito.enganche).toBe('217500') + }) + + it('montos en cero quedan vacíos (no inventa "0")', () => { + const pre = cotizacionAPrellenado({ ...base, precioFinal: 0, enganche: 0 }) + expect(pre.viviendaCredito.valorVenta).toBe('') + expect(pre.viviendaCredito.enganche).toBe('') + }) +}) diff --git a/apps/alta-clientes/src/lib/cotizacionAlta.ts b/apps/alta-clientes/src/lib/cotizacionAlta.ts new file mode 100644 index 0000000..b0064a6 --- /dev/null +++ b/apps/alta-clientes/src/lib/cotizacionAlta.ts @@ -0,0 +1,44 @@ +import type { DatosGenerales, ViviendaCredito } from '../types' +import { PROYECTOS } from '../data/constantes' + +/** Shape mínimo de GET /api/cotizaciones/:id que usa el pre-llenado del alta */ +export interface CotizacionParaAlta { + clienteNombre: string + clienteEmail: string | null + clienteTelefono: string | null + desarrollo: string + unidadCodigo: string + precioFinal: number + enganche: number +} + +export interface PrellenadoAlta { + datosGenerales: Partial + viviendaCredito: Partial +} + +/** + * Transformación pura cotización → campos del wizard de alta. + * El desarrollo se mapea al dropdown de proyecto; si no está en PROYECTOS cae + * en OTRO con el nombre en texto libre. La "ubicación" del formato es el + * código de unidad (U-101, etc.). Los montos se prellenan como texto. + */ +export function cotizacionAPrellenado(c: CotizacionParaAlta): PrellenadoAlta { + const desarrollo = c.desarrollo.trim().toUpperCase() + const conocido = (PROYECTOS as readonly string[]).includes(desarrollo) + + return { + datosGenerales: { + nombreCompleto: c.clienteNombre.trim().toUpperCase(), + ...(c.clienteEmail ? { email: c.clienteEmail.trim() } : {}), + ...(c.clienteTelefono ? { celular: c.clienteTelefono.trim() } : {}), + }, + viviendaCredito: { + proyecto: conocido ? desarrollo : 'OTRO', + proyectoOtro: conocido ? '' : desarrollo, + ubicacion: c.unidadCodigo, + valorVenta: c.precioFinal > 0 ? String(c.precioFinal) : '', + enganche: c.enganche > 0 ? String(c.enganche) : '', + }, + } +} diff --git a/apps/alta-clientes/src/main.tsx b/apps/alta-clientes/src/main.tsx index 5c1465b..15d10ba 100644 --- a/apps/alta-clientes/src/main.tsx +++ b/apps/alta-clientes/src/main.tsx @@ -7,17 +7,24 @@ import '@fontsource/inter/500.css' import '@fontsource/inter/700.css' import './index.css' import AltaClientePage from './pages/AltaClientePage' +import FirmaRemotaPage from './pages/FirmaRemotaPage' import AuthGate from './components/AuthGate' createRoot(document.getElementById('root')!).render( - - - } /> - } /> - - + + {/* Ruta pública de la liga remota: sin login (el token va en la URL) */} + } /> + + + + } + /> + , ) diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index ebfc32a..b2c3ee8 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import Stepper from '../components/Stepper' import DatosGeneralesForm from '../forms/DatosGeneralesForm' import EmpleoForm from '../forms/EmpleoForm' @@ -9,15 +9,129 @@ import ViviendaCreditoForm from '../forms/ViviendaCreditoForm' import DocumentosForm from '../forms/DocumentosForm' import FirmasForm from '../forms/FirmasForm' import { useAltaCliente, PASOS } from '../hooks/useAltaCliente' -import { crearAltaCliente, subirDocumentos } from '../services/api' +import { + crearAltaCliente, + eliminarBorrador, + generarLigaRemota, + guardarBorrador, + listarBorradores, + obtenerBorrador, + obtenerCotizacion, + subirDocumentos, + type BorradorResumen, +} from '../services/api' +import { cotizacionAPrellenado } from '../lib/cotizacionAlta' + +/** Lee ?cotizacion= del query string o del hash (#cotizacion=) */ +function paramCotizacion(): string | null { + const query = new URLSearchParams(window.location.search).get('cotizacion') + if (query) return query + const hash = window.location.hash.replace(/^#/, '') + return new URLSearchParams(hash).get('cotizacion') +} + +const formatoFechaHora = (iso: string): string => + new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) export default function AltaClientePage() { - const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } = - useAltaCliente() + const { + paso, + siguiente, + anterior, + irAPaso, + datos, + alta, + actualizar, + restaurar, + reiniciar, + agregarDocumento, + quitarDocumento, + } = useAltaCliente() const [registro, setRegistro] = useState('') const [sinConexion, setSinConexion] = useState(false) const [errorValidacion, setErrorValidacion] = useState('') + // ---------- Borradores ---------- + type Pantalla = 'cargando' | 'picker' | 'wizard' + const [pantalla, setPantalla] = useState('cargando') + const [borradores, setBorradores] = useState([]) + const [borradorId, setBorradorId] = useState(null) + const [avisoBorrador, setAvisoBorrador] = useState('') + const [cargoDeBorrador, setCargoDeBorrador] = useState(false) + const [errorCarga, setErrorCarga] = useState('') + + // ---------- Liga remota ---------- + const [ligaUrl, setLigaUrl] = useState('') + const [ligaCopiada, setLigaCopiada] = useState(false) + const [errorLiga, setErrorLiga] = useState('') + + // Al entrar: ?cotizacion= prellena el alta; si no, ofrece borradores + useEffect(() => { + const cotizacionId = paramCotizacion() + if (cotizacionId) { + obtenerCotizacion(cotizacionId) + .then((c) => { + const pre = cotizacionAPrellenado(c) + actualizar('datosGenerales', { ...datos.datosGenerales, ...pre.datosGenerales }) + actualizar('viviendaCredito', { ...datos.viviendaCredito, ...pre.viviendaCredito }) + }) + .catch(() => setErrorCarga('No se pudo cargar la cotización para prellenar el alta.')) + .finally(() => setPantalla('wizard')) + return + } + listarBorradores() + .then((lista) => { + setBorradores(lista) + setPantalla(lista.length > 0 ? 'picker' : 'wizard') + }) + .catch(() => setPantalla('wizard')) // sin conexión: el wizard funciona igual + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const continuarBorrador = async (id: string) => { + try { + const b = await obtenerBorrador(id) + restaurar(JSON.parse(b.datosJson) as typeof datos, b.paso) + setBorradorId(b.id) + setCargoDeBorrador(true) + setPantalla('wizard') + } catch { + setErrorCarga('No se pudo abrir el borrador.') + } + } + + const borrarBorrador = async (id: string) => { + try { + await eliminarBorrador(id) + setBorradores((prev) => { + const restantes = prev.filter((b) => b.id !== id) + if (restantes.length === 0) setPantalla('wizard') + return restantes + }) + } catch { + setErrorCarga('No se pudo eliminar el borrador.') + } + } + + const empezarNuevo = () => { + reiniciar() + setBorradorId(null) + setCargoDeBorrador(false) + setPantalla('wizard') + } + + const guardar = async () => { + try { + const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos }) + setBorradorId(b.id) + setAvisoBorrador( + `Borrador guardado ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`, + ) + } catch { + setAvisoBorrador('No se pudo guardar el borrador') + } + } + // Registra el alta en el API al llegar al paso de firmas; si el API no // responde, el flujo continúa solo local (los PDFs se generan igual) const registrarAlta = async () => { @@ -27,6 +141,11 @@ export default function AltaClientePage() { setRegistro(res.id) setSinConexion(false) setErrorValidacion('') + // El alta ya quedó registrada: el borrador usado ya no hace falta + if (borradorId) { + eliminarBorrador(borradorId).catch(() => undefined) + setBorradorId(null) + } } else if (res.motivo === 'validacion') { setRegistro('') setSinConexion(false) @@ -38,18 +157,119 @@ export default function AltaClientePage() { } } + // Genera la liga pública y copia al portapapeles el texto listo para WhatsApp + const generarLiga = async () => { + setErrorLiga('') + setLigaCopiada(false) + try { + const liga = await generarLigaRemota(registro) + const urlAbsoluta = `${window.location.origin}${liga.url}` + setLigaUrl(urlAbsoluta) + const texto = `Hola ${alta.datosGenerales.nombreCompleto}, te comparto la liga para firmar tu expediente y subir tus documentos (vigencia 72 h): ${urlAbsoluta}` + await navigator.clipboard.writeText(texto) + setLigaCopiada(true) + } catch { + setErrorLiga('No se pudo generar la liga. Intenta de nuevo.') + } + } + + if (pantalla === 'cargando') { + return ( +
+
+

Cargando…

+
+
+ ) + } + + if (pantalla === 'picker') { + return ( +
+
+
+ FRASA +

Alta de Clientes

+
+
+
+

+ Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado + {borradores.length === 1 ? '' : 's'} +

+ {errorCarga &&

{errorCarga}

} +
    + {borradores.map((b) => ( +
  • +
    +

    {b.nombre}

    +

    + Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)} +

    +
    +
    + + +
    +
  • + ))} +
+ +
+
+ ) + } + return (
-
+
FRASA

Alta de Clientes

+
+ {avisoBorrador && ( + + {avisoBorrador} + + )} + +
+ {errorCarga && ( +

+ {errorCarga} +

+ )} +
{paso === 0 && ( )} {paso === 6 && ( - - actualizar('datosGenerales', { ...datos.datosGenerales, ...campos }) - } - onAnterior={anterior} - onSiguiente={() => { - void registrarAlta() - siguiente() - }} - /> + <> + {cargoDeBorrador && ( +

+ Retomaste un borrador: los archivos de los documentos no se guardan en el borrador, + vuelve a adjuntarlos. +

+ )} + + actualizar('datosGenerales', { ...datos.datosGenerales, ...campos }) + } + onAnterior={anterior} + onSiguiente={() => { + void registrarAlta() + siguiente() + }} + /> + )} {paso === 7 && ( <> @@ -149,6 +380,35 @@ export default function AltaClientePage() { El servidor rechazó el alta: {errorValidacion}

)} + {registro && ( +
+

+ Firma en remoto +

+

+ Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus + documentos desde su celular, sin usuario ni contraseña. +

+ + {ligaUrl && ( +

+ {ligaUrl} + {ligaCopiada && ( + + Liga y mensaje de WhatsApp copiados + + )} +

+ )} + {errorLiga &&

{errorLiga}

} +
+ )} actualizar('firma', f)} diff --git a/apps/alta-clientes/src/pages/FirmaRemotaPage.tsx b/apps/alta-clientes/src/pages/FirmaRemotaPage.tsx new file mode 100644 index 0000000..818a3a1 --- /dev/null +++ b/apps/alta-clientes/src/pages/FirmaRemotaPage.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import SignaturePad from '../components/SignaturePad' +import { SLOTS_DOCUMENTOS, TAMANO_MAX_DOCUMENTO } from '../data/constantes' +import { + enviarFirmaRemota, + ErrorLiga, + obtenerClientePublico, + subirDocumentoRemoto, + type ClientePublico, +} from '../services/api' + +const ACEPTADOS = '.pdf,.jpg,.jpeg,.png' + +/** + * Pantalla PÚBLICA de firma en remoto (/client/firma/:token): sin login. + * El cliente final firma y sube sus documentos; el token (scope firma-remota, + * 72 h) va en la URL y lo verifica el API en cada llamada. + */ +export default function FirmaRemotaPage() { + const { token = '' } = useParams() + const [cliente, setCliente] = useState(null) + const [errorCarga, setErrorCarga] = useState('') + + const [firmaDataUrl, setFirmaDataUrl] = useState('') + const [enviandoFirma, setEnviandoFirma] = useState(false) + const [firmaOk, setFirmaOk] = useState(false) + const [errorFirma, setErrorFirma] = useState('') + + const [subiendoSlot, setSubiendoSlot] = useState('') + const [erroresDoc, setErroresDoc] = useState>({}) + + useEffect(() => { + obtenerClientePublico(token) + .then((c) => { + setCliente(c) + setFirmaOk(c.firma) + }) + .catch((err) => + setErrorCarga(err instanceof ErrorLiga ? err.message : 'No se pudo cargar la información. Intenta más tarde.'), + ) + }, [token]) + + const enviarFirma = async () => { + if (!firmaDataUrl) { + setErrorFirma('Primero dibuja tu firma en el recuadro.') + return + } + setEnviandoFirma(true) + setErrorFirma('') + try { + await enviarFirmaRemota(token, firmaDataUrl) + setFirmaOk(true) + } catch (err) { + setErrorFirma(err instanceof ErrorLiga ? err.message : 'No se pudo guardar la firma. Intenta de nuevo.') + } finally { + setEnviandoFirma(false) + } + } + + const subirDocumento = async (slot: string, archivo: File | undefined) => { + if (!archivo) return + if (archivo.size > TAMANO_MAX_DOCUMENTO) { + setErroresDoc((prev) => ({ ...prev, [slot]: 'El archivo supera 10 MB' })) + return + } + setSubiendoSlot(slot) + setErroresDoc((prev) => ({ ...prev, [slot]: '' })) + try { + await subirDocumentoRemoto(token, slot, archivo) + // Refresca el estado de documentos desde el servidor + setCliente(await obtenerClientePublico(token)) + } catch (err) { + setErroresDoc((prev) => ({ + ...prev, + [slot]: err instanceof ErrorLiga ? err.message : 'No se pudo subir el archivo. Intenta de nuevo.', + })) + } finally { + setSubiendoSlot('') + } + } + + if (errorCarga) { + return ( +
+
+ FRASA +

+ {errorCarga} +

+
+
+ ) + } + + if (!cliente) { + return ( +
+
+

Cargando…

+
+
+ ) + } + + return ( +
+
+
+ FRASA +

Expediente digital

+
+
+ +
+
+

+ Hola, {cliente.nombre} +

+

+ Proyecto {cliente.proyecto} + {cliente.unidad ? ` · Unidad ${cliente.unidad}` : ''}. Firma y sube los documentos de tu + expediente desde aquí. +

+
+ +
+

+ Tu firma +

+ {firmaOk ? ( +

+ Firma registrada correctamente +

+ ) : ( +
+ + {errorFirma &&

{errorFirma}

} + +
+ )} +
+ +
+

+ Tus documentos +

+
    + {SLOTS_DOCUMENTOS.map(({ slot, etiqueta }) => { + const subido = cliente.documentos.find((d) => d.tipo === slot) + return ( +
  • +

    {etiqueta}

    + {subido ? ( +

    + ✓ {subido.nombreArchivo} +

    + ) : ( +
    + +

    PDF/JPG/PNG · máx 10 MB

    +
    + )} + {erroresDoc[slot] && ( +

    + {erroresDoc[slot]} +

    + )} +
  • + ) + })} +
+
+
+
+ ) +} diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index afa6e58..7d22efe 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -97,3 +97,143 @@ export async function subirDocumentos(clienteId: string, payload: AltaCliente): } } } + +// ---------- Borradores del alta (auth) ---------- + +export interface BorradorResumen { + id: string + nombre: string + paso: number + createdAt: string + updatedAt: string +} + +export interface Borrador extends BorradorResumen { + datosJson: string +} + +/** GET /borradores — los míos, sin datosJson */ +export async function listarBorradores(): Promise { + const res = await fetch(`${API_URL}/borradores`, conAuth()) + manejar401(res) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as BorradorResumen[] +} + +/** POST /borradores — upsert: con `id` actualiza, sin `id` crea */ +export async function guardarBorrador(payload: { + id?: string + paso: number + datos: unknown +}): Promise { + const res = await fetch( + `${API_URL}/borradores`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }), + ) + manejar401(res) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as Borrador +} + +/** GET /borradores/:id — borrador completo */ +export async function obtenerBorrador(id: string): Promise { + const res = await fetch(`${API_URL}/borradores/${id}`, conAuth()) + manejar401(res) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as Borrador +} + +/** DELETE /borradores/:id */ +export async function eliminarBorrador(id: string): Promise { + const res = await fetch(`${API_URL}/borradores/${id}`, conAuth({ method: 'DELETE' })) + manejar401(res) + if (!res.ok) throw new Error(`HTTP ${res.status}`) +} + +// ---------- Pre-llenado desde cotización (auth) ---------- + +/** GET /cotizaciones/:id — datos para prellenar el alta (?cotizacion=) */ +export async function obtenerCotizacion(id: string): Promise { + const res = await fetch(`${API_URL}/cotizaciones/${id}`, conAuth()) + manejar401(res) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as import('../lib/cotizacionAlta').CotizacionParaAlta +} + +// ---------- Liga remota de firma (auth) ---------- + +export interface LigaRemota { + token: string + url: string // ruta pública relativa: /client/firma/ + expiraEn: string +} + +/** POST /clientes/:id/liga — genera la liga pública de firma/documentos */ +export async function generarLigaRemota(clienteId: string): Promise { + const res = await fetch(`${API_URL}/clientes/${clienteId}/liga`, conAuth({ method: 'POST' })) + manejar401(res) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as LigaRemota +} + +// ---------- Endpoints públicos de la liga (SIN auth; el token va en la URL) ---------- + +export interface DocumentoPublico { + tipo: string + nombreArchivo: string + createdAt: string +} + +export interface ClientePublico { + nombre: string + proyecto: string + unidad: string | null + firma: boolean + documentos: DocumentoPublico[] +} + +/** Error de liga inválida/expirada con mensaje para el cliente final */ +export class ErrorLiga extends Error { + constructor(message = 'La liga es inválida o ya expiró. Pide una nueva a tu asesor.') { + super(message) + this.name = 'ErrorLiga' + } +} + +const manejarErrorLiga = (res: Response): void => { + if (res.status === 401 || res.status === 404) throw new ErrorLiga() + if (!res.ok) throw new Error(`HTTP ${res.status}`) +} + +/** GET /clientes/publico/:token — solo nombre, proyecto, unidad y estado */ +export async function obtenerClientePublico(token: string): Promise { + const res = await fetch(`${API_URL}/clientes/publico/${token}`) + manejarErrorLiga(res) + return (await res.json()) as ClientePublico +} + +/** POST /clientes/publico/:token/firma — guarda la firma (PNG data URL) */ +export async function enviarFirmaRemota(token: string, firmaDataUrl: string): Promise { + const res = await fetch(`${API_URL}/clientes/publico/${token}/firma`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ firmaDataUrl }), + }) + manejarErrorLiga(res) +} + +/** POST /clientes/publico/:token/documentos — adjunta UN documento por slot */ +export async function subirDocumentoRemoto(token: string, tipo: string, archivo: File): Promise { + const form = new FormData() + form.append('tipo', tipo) + form.append('archivos', archivo, archivo.name) + const res = await fetch(`${API_URL}/clientes/publico/${token}/documentos`, { + method: 'POST', + body: form, + }) + manejarErrorLiga(res) +} diff --git a/apps/cotizador/src/components/MisCotizaciones.tsx b/apps/cotizador/src/components/MisCotizaciones.tsx index c66fc1e..2449a01 100644 --- a/apps/cotizador/src/components/MisCotizaciones.tsx +++ b/apps/cotizador/src/components/MisCotizaciones.tsx @@ -99,13 +99,21 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr {formatoMoneda(c.precioFinal)} - +
+ + + Iniciar alta + +
))}