Skip to main content

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étodoPara 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 booleano

Devuelve 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.

Dos trampas del contrato
  • ok(data, totalRows) ignora totalRows si es 0. Con cero resultados el campo no viaja: en el cliente tratalo como totalRows ?? 0.
  • error(e) no llena message. Solo err. 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:

CampoNotas
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 con import normal, no import type: reflect-metadata lo 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:

CapaQué hay
v1El grueso: 20 áreas de controllers y ~55 servicios
v2Autorizaciones, remitos, reportes, firmas, emails, PAMI
v3Solo 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 de Error genérico.
  • Fechas: siempre getMoment() / getDate() de @utils/DateUtils, nunca moment() ni new Date() a pelo.
  • Textos de usuario: i18next + TranslationService, con los locales en src/config/locales/{es,en}/translation.json.

Husky

  • pre-commitlint-staged: Prettier + ESLint + jest --findRelatedTests solo sobre lo staged.
  • pre-push → auditoría de seguridad + la suite completa.

No uses --no-verify salvo emergencia real.

Utilidades que ya existen

UtilidadQué resuelve
@utils/DateUtilsgetMoment() / getDate() con resetTime, srcFormat estricto, set/add
@utils/CryptoUtilsAES-256-CBC y AES-256-GCM, generateToken() (JWT), sha256File/sha256Buffer
@utils/SearchUtilsBú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/SpErrorUtilsTraduce los códigos de error de los stored procedures
@utils/GrantUtilsisPublicRoute()
exportRawQueryToExcelExport a Excel por streaming, para no volar la memoria en reportes grandes