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:
@@ -73,6 +73,21 @@ model Documento {
|
|||||||
@@index([clienteId])
|
@@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 {
|
model PlanPago {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
ventaRef String?
|
ventaRef String?
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export const config = {
|
|||||||
// Password del usuario admin sembrado (seed-usuarios.ts)
|
// Password del usuario admin sembrado (seed-usuarios.ts)
|
||||||
adminPassword: env('ADMIN_PASSWORD', 'Frasa2026'),
|
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
|
} as const
|
||||||
|
|
||||||
export type AppConfig = typeof config
|
export type AppConfig = typeof config
|
||||||
|
|||||||
180
api-gateway/src/controllers/borradores.controller.test.ts
Normal file
180
api-gateway/src/controllers/borradores.controller.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
81
api-gateway/src/controllers/borradores.controller.ts
Normal file
81
api-gateway/src/controllers/borradores.controller.ts
Normal 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()
|
||||||
|
}
|
||||||
249
api-gateway/src/controllers/liga.controller.test.ts
Normal file
249
api-gateway/src/controllers/liga.controller.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
234
api-gateway/src/controllers/liga.controller.ts
Normal file
234
api-gateway/src/controllers/liga.controller.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -80,6 +80,36 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
|
|||||||
expect(next).toHaveBeenCalledOnce()
|
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', () => {
|
it('el patrón público no abre otras rutas de cotizaciones', () => {
|
||||||
const casos: Partial<Request>[] = [
|
const casos: Partial<Request>[] = [
|
||||||
{ method: 'GET', path: '/cotizaciones/abc-123' },
|
{ method: 'GET', path: '/cotizaciones/abc-123' },
|
||||||
|
|||||||
@@ -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.
|
// final lo abre sin sesión; devuelve solo datos comerciales, nunca del cliente.
|
||||||
const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
|
const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
|
||||||
{ metodo: 'GET', patron: /^\/cotizaciones\/[^/]+\/publico$/ },
|
{ 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' {
|
declare module 'express-serve-static-core' {
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ import {
|
|||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
uploadDocumentos,
|
uploadDocumentos,
|
||||||
} from '../controllers/clientes.controller.js'
|
} 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 {
|
import {
|
||||||
agregarNota,
|
agregarNota,
|
||||||
cancelarPlan,
|
cancelarPlan,
|
||||||
@@ -69,10 +83,26 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
|||||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||||
|
|
||||||
apiRouter.post('/clientes', crearCliente)
|
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.get('/clientes/:id', obtenerCliente)
|
||||||
apiRouter.post('/clientes/:id/documentos', uploadDocumentos.array('archivos', 10), subirDocumentos)
|
apiRouter.post('/clientes/:id/documentos', uploadDocumentos.array('archivos', 10), subirDocumentos)
|
||||||
apiRouter.get('/clientes/:id/documentos', listarDocumentos)
|
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.post('/planes', crearPlan)
|
||||||
apiRouter.get('/planes', listarPlanes)
|
apiRouter.get('/planes', listarPlanes)
|
||||||
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
|
apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy)
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ export interface TokenPayload {
|
|||||||
/** Verifica un Bearer token y devuelve su payload; lanza 401 si no es válido */
|
/** Verifica un Bearer token y devuelve su payload; lanza 401 si no es válido */
|
||||||
export function verificarToken(token: string): TokenPayload {
|
export function verificarToken(token: string): TokenPayload {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
throw new AppError(401, 'Token inválido o expirado')
|
throw new AppError(401, 'Token inválido o expirado')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ export function useAltaCliente() {
|
|||||||
const anterior = () => setPaso((p) => Math.max(p - 1, 0))
|
const anterior = () => setPaso((p) => Math.max(p - 1, 0))
|
||||||
const irAPaso = (p: number) => setPaso(p)
|
const irAPaso = (p: number) => setPaso(p)
|
||||||
|
|
||||||
|
/** Restaura el estado completo (p. ej. desde un borrador) y salta a un paso */
|
||||||
|
const restaurar = (nuevosDatos: ReturnType<typeof estadoInicial>, pasoDestino = 0) => {
|
||||||
|
setDatos(nuevosDatos)
|
||||||
|
setPaso(pasoDestino)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vuelve al estado inicial (alta nueva) */
|
||||||
|
const reiniciar = () => {
|
||||||
|
setDatos(estadoInicial())
|
||||||
|
setPaso(0)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paso,
|
paso,
|
||||||
siguiente,
|
siguiente,
|
||||||
@@ -142,6 +154,8 @@ export function useAltaCliente() {
|
|||||||
datos,
|
datos,
|
||||||
alta,
|
alta,
|
||||||
actualizar,
|
actualizar,
|
||||||
|
restaurar,
|
||||||
|
reiniciar,
|
||||||
agregarDocumento,
|
agregarDocumento,
|
||||||
quitarDocumento,
|
quitarDocumento,
|
||||||
}
|
}
|
||||||
|
|||||||
54
apps/alta-clientes/src/lib/cotizacionAlta.test.ts
Normal file
54
apps/alta-clientes/src/lib/cotizacionAlta.test.ts
Normal file
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
44
apps/alta-clientes/src/lib/cotizacionAlta.ts
Normal file
44
apps/alta-clientes/src/lib/cotizacionAlta.ts
Normal file
@@ -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<DatosGenerales>
|
||||||
|
viviendaCredito: Partial<ViviendaCredito>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) : '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,24 @@ import '@fontsource/inter/500.css'
|
|||||||
import '@fontsource/inter/700.css'
|
import '@fontsource/inter/700.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import AltaClientePage from './pages/AltaClientePage'
|
import AltaClientePage from './pages/AltaClientePage'
|
||||||
|
import FirmaRemotaPage from './pages/FirmaRemotaPage'
|
||||||
import AuthGate from './components/AuthGate'
|
import AuthGate from './components/AuthGate'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter basename="/client">
|
<BrowserRouter basename="/client">
|
||||||
<AuthGate>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AltaClientePage />} />
|
{/* Ruta pública de la liga remota: sin login (el token va en la URL) */}
|
||||||
<Route path="*" element={<AltaClientePage />} />
|
<Route path="/firma/:token" element={<FirmaRemotaPage />} />
|
||||||
</Routes>
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<AuthGate>
|
||||||
|
<AltaClientePage />
|
||||||
</AuthGate>
|
</AuthGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Stepper from '../components/Stepper'
|
import Stepper from '../components/Stepper'
|
||||||
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
||||||
import EmpleoForm from '../forms/EmpleoForm'
|
import EmpleoForm from '../forms/EmpleoForm'
|
||||||
@@ -9,15 +9,129 @@ import ViviendaCreditoForm from '../forms/ViviendaCreditoForm'
|
|||||||
import DocumentosForm from '../forms/DocumentosForm'
|
import DocumentosForm from '../forms/DocumentosForm'
|
||||||
import FirmasForm from '../forms/FirmasForm'
|
import FirmasForm from '../forms/FirmasForm'
|
||||||
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
|
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=<id> del query string o del hash (#cotizacion=<id>) */
|
||||||
|
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() {
|
export default function AltaClientePage() {
|
||||||
const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } =
|
const {
|
||||||
useAltaCliente()
|
paso,
|
||||||
|
siguiente,
|
||||||
|
anterior,
|
||||||
|
irAPaso,
|
||||||
|
datos,
|
||||||
|
alta,
|
||||||
|
actualizar,
|
||||||
|
restaurar,
|
||||||
|
reiniciar,
|
||||||
|
agregarDocumento,
|
||||||
|
quitarDocumento,
|
||||||
|
} = useAltaCliente()
|
||||||
const [registro, setRegistro] = useState<string>('')
|
const [registro, setRegistro] = useState<string>('')
|
||||||
const [sinConexion, setSinConexion] = useState(false)
|
const [sinConexion, setSinConexion] = useState(false)
|
||||||
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
||||||
|
|
||||||
|
// ---------- Borradores ----------
|
||||||
|
type Pantalla = 'cargando' | 'picker' | 'wizard'
|
||||||
|
const [pantalla, setPantalla] = useState<Pantalla>('cargando')
|
||||||
|
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
||||||
|
const [borradorId, setBorradorId] = useState<string | null>(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=<id> 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
|
// 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)
|
// responde, el flujo continúa solo local (los PDFs se generan igual)
|
||||||
const registrarAlta = async () => {
|
const registrarAlta = async () => {
|
||||||
@@ -27,6 +141,11 @@ export default function AltaClientePage() {
|
|||||||
setRegistro(res.id)
|
setRegistro(res.id)
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
setErrorValidacion('')
|
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') {
|
} else if (res.motivo === 'validacion') {
|
||||||
setRegistro('')
|
setRegistro('')
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
@@ -38,6 +157,33 @@ 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 (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||||
|
<p className="text-sm text-neutral-500">Cargando…</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pantalla === 'picker') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<header className="border-b border-neutral-200 px-4 py-6">
|
<header className="border-b border-neutral-200 px-4 py-6">
|
||||||
@@ -46,10 +192,84 @@ export default function AltaClientePage() {
|
|||||||
<h1 className="text-2xl font-light tracking-wide">Alta de Clientes</h1>
|
<h1 className="text-2xl font-light tracking-wide">Alta de Clientes</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||||
|
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
||||||
|
{borradores.length === 1 ? '' : 's'}
|
||||||
|
</h2>
|
||||||
|
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
|
||||||
|
<ul className="mt-6 divide-y divide-neutral-200 border border-neutral-200 bg-white">
|
||||||
|
{borradores.map((b) => (
|
||||||
|
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm text-neutral-900">{b.nombre}</p>
|
||||||
|
<p className="mt-1 text-xs font-light text-neutral-500">
|
||||||
|
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void continuarBorrador(b.id)}
|
||||||
|
className="border border-accent bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void borrarBorrador(b.id)}
|
||||||
|
className="border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={empezarNuevo}
|
||||||
|
className="mt-8 bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
Empezar nuevo
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<header className="border-b border-neutral-200 px-4 py-6">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline gap-3">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||||
|
<h1 className="text-2xl font-light tracking-wide">Alta de Clientes</h1>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{avisoBorrador && (
|
||||||
|
<span role="status" className="text-xs font-light text-neutral-500">
|
||||||
|
{avisoBorrador}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void guardar()}
|
||||||
|
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||||
|
>
|
||||||
|
Guardar borrador
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl px-4 py-16">
|
<main className="mx-auto max-w-6xl px-4 py-16">
|
||||||
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
|
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
|
||||||
|
|
||||||
|
{errorCarga && (
|
||||||
|
<p role="alert" className="mt-8 border border-amber-300 bg-amber-50 px-4 py-3 text-xs text-amber-700">
|
||||||
|
{errorCarga}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
{paso === 0 && (
|
{paso === 0 && (
|
||||||
<DatosGeneralesForm
|
<DatosGeneralesForm
|
||||||
@@ -111,6 +331,16 @@ export default function AltaClientePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{paso === 6 && (
|
{paso === 6 && (
|
||||||
|
<>
|
||||||
|
{cargoDeBorrador && (
|
||||||
|
<p
|
||||||
|
role="note"
|
||||||
|
className="mb-8 border border-amber-300 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
||||||
|
>
|
||||||
|
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
|
||||||
|
vuelve a adjuntarlos.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<DocumentosForm
|
<DocumentosForm
|
||||||
documentos={alta.documentos}
|
documentos={alta.documentos}
|
||||||
firmaDataUrl={alta.firma.firmaDataUrl}
|
firmaDataUrl={alta.firma.firmaDataUrl}
|
||||||
@@ -125,6 +355,7 @@ export default function AltaClientePage() {
|
|||||||
siguiente()
|
siguiente()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{paso === 7 && (
|
{paso === 7 && (
|
||||||
<>
|
<>
|
||||||
@@ -149,6 +380,35 @@ export default function AltaClientePage() {
|
|||||||
El servidor rechazó el alta: {errorValidacion}
|
El servidor rechazó el alta: {errorValidacion}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{registro && (
|
||||||
|
<div className="mb-8 border border-neutral-200 bg-white px-4 py-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Firma en remoto
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs font-light text-neutral-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generarLiga()}
|
||||||
|
className="mt-4 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||||
|
>
|
||||||
|
Generar liga para firmar en remoto
|
||||||
|
</button>
|
||||||
|
{ligaUrl && (
|
||||||
|
<p className="mt-4 break-all text-xs text-neutral-900">
|
||||||
|
{ligaUrl}
|
||||||
|
{ligaCopiada && (
|
||||||
|
<span role="status" className="ml-2 font-bold uppercase tracking-widest text-accent">
|
||||||
|
Liga y mensaje de WhatsApp copiados
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<FirmasForm
|
<FirmasForm
|
||||||
alta={alta}
|
alta={alta}
|
||||||
onCambiarFirma={(f) => actualizar('firma', f)}
|
onCambiarFirma={(f) => actualizar('firma', f)}
|
||||||
|
|||||||
197
apps/alta-clientes/src/pages/FirmaRemotaPage.tsx
Normal file
197
apps/alta-clientes/src/pages/FirmaRemotaPage.tsx
Normal file
@@ -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<ClientePublico | null>(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<Record<string, string>>({})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<main className="mx-auto max-w-xl px-4 py-24 text-center">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||||
|
<p role="alert" className="mt-8 border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{errorCarga}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cliente) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<main className="mx-auto max-w-xl px-4 py-24">
|
||||||
|
<p className="text-sm text-neutral-500">Cargando…</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white text-neutral-900">
|
||||||
|
<header className="border-b border-neutral-200 px-4 py-6">
|
||||||
|
<div className="mx-auto flex max-w-xl items-baseline gap-3">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
|
||||||
|
<h1 className="text-2xl font-light tracking-wide">Expediente digital</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-xl space-y-12 px-4 py-12">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-light">
|
||||||
|
Hola, <span className="font-medium">{cliente.nombre}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm font-light text-neutral-500">
|
||||||
|
Proyecto {cliente.proyecto}
|
||||||
|
{cliente.unidad ? ` · Unidad ${cliente.unidad}` : ''}. Firma y sube los documentos de tu
|
||||||
|
expediente desde aquí.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="firma-titulo">
|
||||||
|
<h3 id="firma-titulo" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Tu firma
|
||||||
|
</h3>
|
||||||
|
{firmaOk ? (
|
||||||
|
<p role="status" className="mt-4 border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
|
||||||
|
Firma registrada correctamente
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4">
|
||||||
|
<SignaturePad onFirma={setFirmaDataUrl} />
|
||||||
|
{errorFirma && <p className="mt-2 text-xs text-red-700">{errorFirma}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={enviandoFirma}
|
||||||
|
onClick={() => void enviarFirma()}
|
||||||
|
className="mt-4 bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{enviandoFirma ? 'Enviando…' : 'Enviar firma'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="docs-titulo">
|
||||||
|
<h3 id="docs-titulo" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||||
|
Tus documentos
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-4 space-y-4">
|
||||||
|
{SLOTS_DOCUMENTOS.map(({ slot, etiqueta }) => {
|
||||||
|
const subido = cliente.documentos.find((d) => d.tipo === slot)
|
||||||
|
return (
|
||||||
|
<li key={slot} className="border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
|
||||||
|
{subido ? (
|
||||||
|
<p role="status" className="mt-2 text-sm text-neutral-900">
|
||||||
|
✓ {subido.nombreArchivo}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="inline-block cursor-pointer border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white">
|
||||||
|
{subiendoSlot === slot ? 'Subiendo…' : 'Seleccionar archivo'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={ACEPTADOS}
|
||||||
|
disabled={subiendoSlot !== ''}
|
||||||
|
aria-label={`Seleccionar archivo para ${etiqueta}`}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => {
|
||||||
|
void subirDocumento(slot, e.target.files?.[0])
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="mt-2 text-xs font-light text-neutral-500">PDF/JPG/PNG · máx 10 MB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{erroresDoc[slot] && (
|
||||||
|
<p role="alert" className="mt-2 text-xs text-red-700">
|
||||||
|
{erroresDoc[slot]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<BorradorResumen[]> {
|
||||||
|
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<Borrador> {
|
||||||
|
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<Borrador> {
|
||||||
|
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<void> {
|
||||||
|
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=<id>) */
|
||||||
|
export async function obtenerCotizacion(id: string): Promise<import('../lib/cotizacionAlta').CotizacionParaAlta> {
|
||||||
|
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/<token>
|
||||||
|
expiraEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /clientes/:id/liga — genera la liga pública de firma/documentos */
|
||||||
|
export async function generarLigaRemota(clienteId: string): Promise<LigaRemota> {
|
||||||
|
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<ClientePublico> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
|||||||
{formatoMoneda(c.precioFinal)}
|
{formatoMoneda(c.precioFinal)}
|
||||||
</td>
|
</td>
|
||||||
<td className="border-t border-neutral-200 p-3 text-right">
|
<td className="border-t border-neutral-200 p-3 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRetomar(c)}
|
onClick={() => onRetomar(c)}
|
||||||
@@ -106,6 +107,13 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
|||||||
>
|
>
|
||||||
Retomar
|
Retomar
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/client/?cotizacion=${c.id}`}
|
||||||
|
className="border border-neutral-900 bg-white px-3 py-1.5 text-xs font-bold tracking-widest text-neutral-900 uppercase transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||||
|
>
|
||||||
|
Iniciar alta
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user