feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API

- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor,
  column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes,
  webhook con handshake challenge, Prisma SQLite dev, 55 tests
- cotizador: inventario desde /api/unidades con fallback a seed
- alta-clientes: POST /api/clientes + upload multipart de documentos
- cobranza: persistencia via API con respaldo offline en localStorage
- .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
2026-08-21 23:39:17 +00:00
parent ecd4edac02
commit 0546f35d2a
48 changed files with 7129 additions and 66 deletions

View File

@@ -10,19 +10,25 @@ import DocumentosForm from '../forms/DocumentosForm'
import FirmasForm from '../forms/FirmasForm'
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
import { crearAltaCliente, subirDocumentos } from '../services/api'
import { enviarAMonday } from '../services/monday'
export default function AltaClientePage() {
const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } =
useAltaCliente()
const [registro, setRegistro] = useState<string>('')
const [sinConexion, setSinConexion] = useState(false)
// Registra el alta (stub API + Monday) al llegar al paso de firmas
// 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 () => {
const { id } = await crearAltaCliente(alta)
await subirDocumentos(id, alta)
await enviarAMonday(alta)
setRegistro(id)
const creada = await crearAltaCliente(alta)
if (creada) {
await subirDocumentos(creada.id, alta)
setRegistro(creada.id)
setSinConexion(false)
} else {
setRegistro('')
setSinConexion(true)
}
}
return (
@@ -117,7 +123,15 @@ export default function AltaClientePage() {
<>
{registro && (
<p className="mb-8 border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
Alta registrada (stub API): {registro}
Alta registrada en el servidor: {registro}
</p>
)}
{sinConexion && (
<p
role="alert"
className="mb-8 border border-amber-300 bg-amber-50 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700"
>
Sin conexión al servidor — el expediente se generó solo localmente
</p>
)}
<FirmasForm

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { AltaCliente } from '../types'
import { fixtureAlta } from '../lib/fixture'
import { crearAltaCliente, subirDocumentos } from './api'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const docPrueba = (): AltaCliente['documentos'] => [
{
slot: 'INE',
nombre: 'ine.pdf',
tamano: 4,
tipoMime: 'application/pdf',
archivo: new File(['pdf!'], 'ine.pdf', { type: 'application/pdf' }),
},
{
slot: 'CURP',
nombre: 'curp.png',
tamano: 4,
tipoMime: 'image/png',
archivo: new File(['png!'], 'curp.png', { type: 'image/png' }),
},
]
const altaConDocumentos = (): AltaCliente => ({ ...fixtureAlta, documentos: docPrueba() })
describe('services/api', () => {
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
warnSpy.mockRestore()
})
describe('crearAltaCliente', () => {
it('hace POST JSON a /clientes y devuelve el id del servidor', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ id: 'srv-123' }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
)
const alta = altaConDocumentos()
const resultado = await crearAltaCliente(alta)
expect(resultado).toEqual({ id: 'srv-123' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://192.168.10.123:3000/api/clientes')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
// Los File no viajan en el JSON: solo metadatos; la firma va como string
const cuerpo = JSON.parse(String(init.body)) as Record<string, unknown>
const documentos = cuerpo['documentos'] as Record<string, unknown>[]
expect(documentos).toEqual([
{ tipo: 'INE', nombre: 'ine.pdf', tamano: 4, mime: 'application/pdf' },
{ tipo: 'CURP', nombre: 'curp.png', tamano: 4, mime: 'image/png' },
])
expect(JSON.stringify(cuerpo)).not.toContain('archivo')
expect((cuerpo['firma'] as Record<string, unknown>)['firmaDataUrl']).toBe(
fixtureAlta.firma.firmaDataUrl,
)
})
it('ante un fallo de red devuelve null y avisa con console.warn', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull()
expect(warnSpy).toHaveBeenCalled()
})
it('ante una respuesta HTTP de error devuelve null y avisa', async () => {
fetchMock.mockResolvedValue(new Response('error', { status: 500 }))
const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull()
expect(warnSpy).toHaveBeenCalled()
})
})
describe('subirDocumentos', () => {
it('arma el multipart correcto: una petición por documento con tipo y archivos', async () => {
fetchMock.mockResolvedValue(new Response('[]', { status: 201 }))
await subirDocumentos('srv-123', altaConDocumentos())
expect(fetchMock).toHaveBeenCalledTimes(2)
const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url1).toBe('http://192.168.10.123:3000/api/clientes/srv-123/documentos')
expect(init1.method).toBe('POST')
// Sin Content-Type manual: el navegador pone el boundary del multipart
expect(init1.headers).toBeUndefined()
const form1 = init1.body as FormData
expect(form1).toBeInstanceOf(FormData)
expect(form1.get('tipo')).toBe('INE')
const archivo1 = form1.get('archivos')
expect(archivo1).toBeInstanceOf(File)
expect((archivo1 as File).name).toBe('ine.pdf')
expect((archivo1 as File).type).toBe('application/pdf')
const [, init2] = fetchMock.mock.calls[1] as [string, RequestInit]
const form2 = init2.body as FormData
expect(form2.get('tipo')).toBe('CURP')
expect((form2.get('archivos') as File).name).toBe('curp.png')
})
it('no hace ninguna petición si no hay documentos', async () => {
await subirDocumentos('srv-123', fixtureAlta)
expect(fetchMock).not.toHaveBeenCalled()
})
it('ante un fallo de red avisa y continúa sin lanzar', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
await expect(subirDocumentos('srv-123', altaConDocumentos())).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -1,21 +1,71 @@
import type { AltaCliente } from '../types'
import { uuid } from '../lib/uuid'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
export interface AltaClienteCreada {
id: string
}
/** Registra el alta de cliente en el backend */
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada> {
// TODO: POST /api/clientes
await new Promise((resolve) => setTimeout(resolve, 400))
console.log('[api] crearAltaCliente (stub):', payload.datosGenerales.nombreCompleto)
return { id: uuid() }
interface DocumentoMeta {
tipo: string
nombre: string
tamano: number
mime: string
}
/** Sube los documentos del expediente del cliente */
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
// TODO: POST multipart a /api/clientes/:id/documentos
await new Promise((resolve) => setTimeout(resolve, 200))
console.log(`[api] subirDocumentos (stub) cliente=${clienteId}:`, payload.documentos.length, 'archivos')
/**
* Serializa el alta para el POST JSON: los File no viajan en el cuerpo,
* solo sus metadatos; la firma ya es un string base64 (data URL).
*/
const serializarAlta = (payload: AltaCliente) => ({
...payload,
documentos: payload.documentos.map(
(d): DocumentoMeta => ({ tipo: d.slot, nombre: d.nombre, tamano: d.tamano, mime: d.tipoMime }),
),
})
/**
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
* SQLite y replica en Monday. Si el API no responde devuelve null (con
* console.warn) y el flujo continúa solo local: los PDFs se generan igual.
*/
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada | null> {
try {
const res = await fetch(`${API_URL}/clientes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serializarAlta(payload)),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as { id: string }
return { id: data.id }
} catch (err) {
console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', err)
return null
}
}
/**
* Sube los documentos reales del expediente (POST multipart
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
* `tipo` por petición, así que se envía una petición por documento con su
* slot como tipo. Los fallos se reportan con console.warn sin interrumpir
* el flujo local.
*/
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
for (const doc of payload.documentos) {
try {
const form = new FormData()
form.append('tipo', doc.slot)
form.append('archivos', doc.archivo, doc.nombre)
const res = await fetch(`${API_URL}/clientes/${clienteId}/documentos`, {
method: 'POST',
body: form,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch (err) {
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
}
}
}

View File

@@ -1,10 +1,13 @@
import type { AltaCliente } from '../types'
/** Envía el alta al tablero de Monday (Contactos + Apartado) */
/**
* OBSOLETO: la réplica en Monday (tableros Contactos y Apartado) ya la hace
* el API Gateway en POST /api/clientes (ver services/api.ts y
* api-gateway/src/controllers/clientes.controller.ts). Se conserva el
* archivo solo como referencia; no llamar desde el flujo del wizard.
*/
export async function enviarAMonday(payload: AltaCliente): Promise<void> {
// TODO: integración Monday — crear item en tablero "Contactos" y "Apartado"
// con nombre, teléfono, correo, proyecto y ubicación del cliente
console.log('[monday] enviarAMonday (stub):', {
console.warn('[monday] enviarAMonday está obsoleto: el backend ya replica en Monday', {
nombre: payload.datosGenerales.nombreCompleto,
proyecto: payload.viviendaCredito.proyecto,
ubicacion: payload.viviendaCredito.ubicacion,