feat(cotizador): historial, compartir WhatsApp, folio+QR, comparador de unidades, alertas de inventario

- Mis cotizaciones (guardar con cliente, retomar, nueva) asociadas al vendedor del token
- Compartir: resumen copiable + deep link wa.me; PDF con folio, vigencia y QR
- GET /api/cotizaciones/:id/publico (público, datos mínimos sin PII del cliente)
- Comparador lado a lado de 2 unidades (mismo o distinto desarrollo)
- POST /api/cotizaciones/alertas-inventario: avisa al vendedor si una unidad
  cotizada cambia de estatus (dedup; wire con syncs documentado)
- Fix: labels de propuesta mapeados a los reales del board Oportunidades
- Tests: api 201, cotizador 82
This commit is contained in:
2026-08-28 06:58:10 +00:00
parent bc0f9da8fe
commit 4f31d1c171
24 changed files with 1938 additions and 20 deletions

View File

@@ -70,6 +70,30 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
}
})
it('deja pasar GET /cotizaciones/:id/publico sin token (destino del QR)', () => {
vi.clearAllMocks()
authMiddleware(
reqFake({ method: 'GET', path: '/cotizaciones/abc-123/publico' }),
resFake().res,
next,
)
expect(next).toHaveBeenCalledOnce()
})
it('el patrón público no abre otras rutas de cotizaciones', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/cotizaciones/abc-123' },
{ method: 'GET', path: '/cotizaciones/mias' },
{ method: 'POST', path: '/cotizaciones/abc-123/publico' },
{ method: 'GET', path: '/cotizaciones/abc-123/publico/extra' },
]
for (const c of casos) {
const { res, status } = resFake()
authMiddleware(reqFake(c), res, next)
expect(status).toHaveBeenCalledWith(401)
}
})
it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => {
const { res, status } = resFake()
authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next)

View File

@@ -12,6 +12,13 @@ const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [
{ metodo: 'POST', ruta: '/webhooks/monday' },
]
// Rutas públicas con parámetros (se comparan contra req.path relativo a /api).
// El endpoint público de cotización es el destino del QR del PDF: el cliente
// 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$/ },
]
declare module 'express-serve-static-core' {
interface Request {
usuarioToken?: TokenPayload
@@ -28,7 +35,9 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return
}
const publica = RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta)
const publica =
RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta) ||
PATRONES_PUBLICOS.some((r) => r.metodo === req.method && r.patron.test(req.path))
if (publica) {
next()
return