Backend
oxitesa-backend: Node 20 + TypeScript (CommonJS), Express vía routing-controllers, TypeORM sobre
MySQL. Los comentarios y los textos de usuario van en español.
Estructura
src/
├── AppConfig.ts # arma el Express y arranca los módulos
├── index.ts # entry point: API o worker según EXECUTION_MODE
├── WorkerConfigV2.ts # dispatcher de los workers
├── controllers/
│ ├── SecuredController.ts
│ └── v1/<área>/ # + v2/
├── services/
│ ├── Service.ts
│ └── v1/ v2/ v3/
├── dtos/v1/ v2/
├── schemas/v1/<esquema>/ # entidades TypeORM
├── modules/<feature>/ # features nuevas, autocontenidas
├── middlewares/v1/
├── migrations/ # scripts de datos one-shot (TypeScript)
├── config/ # conexiones, errores, i18n, passport
└── utils/
Aparte, en la raíz del repo, migrations/**/*.sql guarda el SQL crudo: stored procedures,
views, fixes y tests. Ver Stored procedures.
Las tres clases base
SecuredController
Todos los controllers la extienden. Da el contexto de usuario y el manejo de errores:
@JsonController("/v1/warehouse")
export class WarehouseController extends SecuredController {
@Get("/me")
async findForMe(@Req() request): Promise<CustomResponse> {
const response = new CustomResponse();
try {
response.ok(await WarehouseService.findMe(this.getUser(request)));
} catch (e) {
return this.unhandledException(request, e);
}
return response;
}
}
| Método | Para qué |
|---|---|
getUser(req, level?) | Arma el User de contexto desde req.userData. No toca la base. |
getUserId(req) | Solo el id numérico. |
hasPermission(role, req) | Compara el nombre del rol contra la base. Devuelve CustomResponse. |
getUserAccess(req) | Acceso completo (usuario + rol + unidades de gestión), cacheado en Redis. |
unhandledException(req, err) | El catch estándar: mensaje traducido + log. |
hasPermission() no devuelve un booleanoDevuelve un CustomResponse. Un if (await this.hasPermission(...)) siempre es verdadero y deja
pasar a cualquiera. El campo a mirar es success:
const perm = await this.hasPermission(BasicRoles.ADMIN, request);
if (!perm.success) return perm;
Service
Base de todos los services; se exportan como singleton (export default new XService()).
class WarehouseService extends Service {
getTarget(): EntityTarget<ObjectLiteral> { return Warehouse; }
async findMe(user: User, queryRunner?: QueryRunner) {
const repository = await this.getRepository(queryRunner); // ojo: es async
return repository.find({ /* … */ });
}
}
getRepository(queryRunner?) es transaction-aware: si no le pasás el queryRunner, la escritura
queda fuera de la transacción del caller. Es la causa más común de "guardó la mitad".
CustomResponse
El contrato de respuesta de todo endpoint (src/config/globals.ts):
class CustomResponse<T = object> {
success: boolean; // arranca en false
message: string;
data: T;
err: Error;
totalRows: number;
ok(data, totalRows?) // success = true
errorMessage(message) // success = false, data = null
error(err) // success = false, data = null, err = err
}
El espejo en la consola web es ApiResponse<TData, TError> y en mobile la interfaz CustomResponse.
ok(data, totalRows)ignoratotalRowssi es0. Con cero resultados el campo no viaja: en el cliente tratalo comototalRows ?? 0.error(e)no llenamessage. Soloerr. Si el motivo tiene que llegar a la UI, seteá el mensaje a mano; si no, el usuario ve un "Error desconocido" genérico.
Transacciones
El molde canónico, tal como está en los controllers:
let response: CustomResponse;
const queryRunner = AtentionProcessService.getQueryRunner();
try {
await AtentionProcessService.startTransaction(queryRunner);
response = await AtentionProcessService.saveOrGet(entity, queryRunner);
} catch (e) {
response = this.unhandledException(request, e);
} finally {
await AtentionProcessService.endTransaction(queryRunner, response);
}
return response;
endTransaction decide sola: commitea si response.success, hace rollback si no, y libera el
queryRunner. Por eso el CustomResponse tiene que estar asignado antes del finally — si te
olvidás de setearlo en el catch, llega undefined y hace rollback en silencio.
DTOs y validación
class-validator + class-transformer. La base de casi todo listado es FindPaginableDTO:
| Campo | Notas |
|---|---|
limit | @Max(100) — no se pueden pedir más de 100 filas |
offset | |
sort?: SortDTO[] | Llega como string JSON en la query y se parsea en un @Transform |
filteredBy? / filteredByValues? | Arrays paralelos |
Reglas:
- DTO anidado necesita los dos:
@Type(() => X)y@ValidateNested(). - Un DTO usado en
@QueryParams()se importa conimportnormal, noimport type:reflect-metadatalo necesita en runtime. - Los errores de validación salen con HTTP 400 real, con forma
{ success: false, message, data: [{ field, messages }] }.
Versionado v1 / v2 / v3
Es aditivo, no un rewrite. Conviven, y el controller elige qué versión usa:
| Capa | Qué hay |
|---|---|
v1 | El grueso: 20 áreas de controllers y ~55 servicios |
v2 | Autorizaciones, remitos, reportes, firmas, emails, PAMI |
v3 | Solo DeliveryNoteServiceV3 — el que invoca los stored procedures de cierre |
Convenciones
- Prettier: 100 caracteres, 4 espacios, comillas dobles, trailing commas.
- Alias de paths:
@config,@controllers,@services,@dtos,@schemas,@middlewares,@modules,@migrations,@utils. Nada de../../... - Errores: tirá las clases de
src/config/errors/(BBDDError,NotFoundError,InvalidParameterError,ServiceError,RabbitSyncError…) en vez deErrorgenérico. - Fechas: siempre
getMoment()/getDate()de@utils/DateUtils, nuncamoment()ninew Date()a pelo. - Textos de usuario:
i18next+TranslationService, con los locales ensrc/config/locales/{es,en}/translation.json.
Husky
- pre-commit →
lint-staged: Prettier + ESLint +jest --findRelatedTestssolo sobre lo staged. - pre-push → auditoría de seguridad + la suite completa.
No uses --no-verify salvo emergencia real.
Utilidades que ya existen
| Utilidad | Qué resuelve |
|---|---|
@utils/DateUtils | getMoment() / getDate() con resetTime, srcFormat estricto, set/add |
@utils/CryptoUtils | AES-256-CBC y AES-256-GCM, generateToken() (JWT), sha256File/sha256Buffer |
@utils/SearchUtils | Búsqueda por nombre token a token con escape de wildcards de LIKE. Existe porque PATIENTS.NAME guarda apellido y nombre en una sola columna: un LIKE del término completo falla si el usuario escribe "ENRIQUE ABAD" en vez de "ABAD ENRIQUE" |
@utils/SpErrorUtils | Traduce los códigos de error de los stored procedures |
@utils/GrantUtils | isPublicRoute() |
exportRawQueryToExcel | Export a Excel por streaming, para no volar la memoria en reportes grandes |