Estructura API
La API de Zentto es un servidor Express + TypeScript que expone endpoints REST organizados por módulos de negocio. Toda la lógica de datos pasa por stored procedures — los servicios TypeScript solo orquestan llamadas y transforman respuestas.
Estructura de directorios
web/api/src/
├── app.ts # Configuración Express + registro de rutas
├── server.ts # Punto de entrada (listen)
├── db/
│ ├── query.ts # callSp, callSpOut, callSpTx
│ ├── sqlserver.ts # Pool SQL Server (mssql)
│ └── postgres.ts # Pool PostgreSQL (pg)
├── middleware/
│ ├── auth.ts # Verificación JWT
│ ├── datetime.ts # Conversión UTC automática
│ ├── errorHandler.ts # Manejo global de errores
│ └── cors.ts # Configuración CORS
├── modules/
│ ├── auth/
│ │ ├── routes.ts
│ │ ├── service.ts
│ │ └── types.ts
│ ├── inventario/
│ │ ├── routes.ts
│ │ ├── service.ts
│ │ └── types.ts
│ ├── contabilidad/
│ │ ├── routes.ts
│ │ ├── service.ts
│ │ └── types.ts
│ ├── facturacion/
│ │ ├── routes.ts
│ │ ├── service.ts
│ │ └── types.ts
│ └── ... # Demás módulos
└── utils/
├── response.ts # Helpers de respuesta estándar
└── validation.ts # Validación de entrada
Patrón de módulo
Cada módulo de negocio se compone de tres archivos con responsabilidades claras:
routes.ts — Definición de endpoints
// web/api/src/modules/inventario/routes.ts
import { Router } from 'express';
import { authMiddleware } from '../../middleware/auth';
import * as service from './service';
const router = Router();
// Todas las rutas requieren autenticación
router.use(authMiddleware);
// GET /v1/inventario/productos
router.get('/productos', async (req, res, next) => {
try {
const { search, page = 1 } = req.query;
const companyId = req.user!.companyId;
const result = await service.listProducts(companyId, search as string, +page);
res.json({ ok: true, ...result });
} catch (err) {
next(err);
}
});
// GET /v1/inventario/productos/:id
router.get('/productos/:id', async (req, res, next) => {
try {
const product = await service.getProductById(req.user!.companyId, +req.params.id);
res.json({ ok: true, data: product });
} catch (err) {
next(err);
}
});
// POST /v1/inventario/productos
router.post('/productos', async (req, res, next) => {
try {
const result = await service.createProduct(req.user!.companyId, req.body);
res.status(201).json({ ok: result.ok, id: result.id, message: result.message });
} catch (err) {
next(err);
}
});
export default router;
service.ts — Lógica de negocio
// web/api/src/modules/inventario/service.ts
import { callSp, callSpOut } from '../../db/query';
import { ProductInput, ProductRow } from './types';
export async function listProducts(companyId: number, search: string, page: number) {
const { rows, outputs } = await callSpOut(
'usp_Master_Product_List',
{ CompanyId: companyId, Search: search || null, PageSize: 20, PageNumber: page },
['TotalCount']
);
return { items: rows as ProductRow[], total: outputs.TotalCount };
}
export async function getProductById(companyId: number, productId: number) {
const rows = await callSp('usp_Master_Product_GetById', {
CompanyId: companyId,
ProductId: productId
});
return rows[0] || null;
}
export async function createProduct(companyId: number, data: ProductInput) {
const { outputs } = await callSpOut(
'usp_Master_Product_Insert',
{ CompanyId: companyId, Name: data.name, Sku: data.sku, Price: data.price },
['Resultado', 'Mensaje']
);
return {
ok: outputs.Resultado > 0,
id: outputs.Resultado,
message: outputs.Mensaje
};
}
types.ts — Interfaces TypeScript
// web/api/src/modules/inventario/types.ts
export interface ProductRow {
ProductId: number;
Name: string;
Sku: string;
Price: number;
Stock: number;
IsActive: boolean;
CreatedAtUtc: string;
}
export interface ProductInput {
name: string;
sku: string;
price: number;
categoryId?: number;
taxId?: number;
}
Registro de rutas en app.ts
// web/api/src/app.ts
import express from 'express';
import cors from 'cors';
import { datetimeMiddleware } from './middleware/datetime';
import { errorHandler } from './middleware/errorHandler';
// Importar rutas de módulos
import authRoutes from './modules/auth/routes';
import inventarioRoutes from './modules/inventario/routes';
import contabilidadRoutes from './modules/contabilidad/routes';
import facturacionRoutes from './modules/facturacion/routes';
import posRoutes from './modules/pos/routes';
// ... demás módulos
const app = express();
// Middleware global
app.use(cors());
app.use(express.json());
app.use(datetimeMiddleware);
// Registrar rutas con prefijo /v1
app.use('/v1/auth', authRoutes);
app.use('/v1/inventario', inventarioRoutes);
app.use('/v1/contabilidad', contabilidadRoutes);
app.use('/v1/facturacion', facturacionRoutes);
app.use('/v1/pos', posRoutes);
// ... demás módulos
// Error handler (siempre al final)
app.use(errorHandler);
export default app;
Patrones de request/response
Response exitoso (lista)
{
"ok": true,
"items": [
{ "ProductId": 1, "Name": "Widget A", "Sku": "WA-001", "Price": 25.50 },
{ "ProductId": 2, "Name": "Widget B", "Sku": "WB-002", "Price": 30.00 }
],
"total": 142
}
Response exitoso (escritura)
{
"ok": true,
"id": 143,
"message": "Producto creado"
}
Response de error
{
"ok": false,
"error": "SKU ya existe",
"statusCode": 400
}
Contrato OpenAPI
Todos los endpoints se documentan en web/contracts/openapi.yaml antes
de implementar en el frontend. Esto garantiza que API y UI trabajen contra la misma especificación.
# web/contracts/openapi.yaml (fragmento)
paths:
/v1/inventario/productos:
get:
summary: Listar productos
tags: [Inventario]
security:
- bearerAuth: []
parameters:
- name: search
in: query
schema: { type: string }
- name: page
in: query
schema: { type: integer, default: 1 }
responses:
'200':
description: Lista paginada de productos
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
items: { type: array, items: { $ref: '#/components/schemas/Product' } }
total: { type: integer }