Contrato de respuesta (CustomResponse)
La regla: ningún endpoint devuelve data cruda. Todos devuelven un CustomResponse.
Ubicación: proteus-backend/src/config/globals.ts.
Espejo en el front: proteus-frontend/src/app/interfaces/api.response.ts (ApiResponse<T>).
class CustomResponse<T = object> {
success: boolean; // arranca en false
message: string;
data: T;
err: Error;
totalRows: number;
ok(data: T, totalRows?: number) // success = true
errorMessage(message: string) // success = false, data = null
error(err: Error) // success = false, data = null, err = err
hasException(): boolean
asEmpty()
}
Uso en un service:
const response = new CustomResponse();
// ...
response.ok(rows, total); // paginado: el total sin paginar va en totalRows
return response;
La consecuencia importante
success: falseNo como 4xx ni 5xx. Por eso el frontend necesita un errorInterceptor que convierta ese
success: false en un throw + toast.
Y por eso un 401 genuino en el front sí significa sesión inválida: el backend usa 500 para errores
internos y 200 + success:false para errores de negocio.
Los dos caminos de error del backend
| Camino | Status HTTP | Forma |
|---|---|---|
| Error de negocio / excepción | 200 (o 500 para internos) | { success: false, message, data: null, err } |
| Error de validación (class-validator) | 400 real | { success: false, message: "Error de validación", data: [{ field, messages }] } |
El segundo lo produce el CustomErrorHandler (src/middlewares/v1/validationErrorMiddleware.ts), y es el
que ejercitan los tests de integración.
Paginación
data = la página, totalRows = el total sin paginar. El contrato de query params es offset / limit
(+ sort como array JSON-stringificado); el armado del ORDER BY desde ese sort lo hace la clase
Service.
Del lado del front, toda tabla es lazy y server-side:
onLazyLoad(event: TableLazyLoadEvent): void {
const offset = event.first ?? 0;
this.first.set(offset);
this.load(offset, event.rows ?? this.limit);
}
El errorInterceptor del frontend
Solo actúa sobre requests a la API. Cuando detecta success === false:
- toastea el
messagey lanza unError, así elcatchErrorde la vista se dispara igual que con un error HTTP; - no toastea si la respuesta trae
data, ni si el mensaje matchea/l[ií]m[ií]t|excedid|exceeded/i(los errores de límite los muestra la vista con su propio copy); - ante un 401 llama
handleSessionExpired()(toast + redirect a login); - ante un 403 no desloguea: es falta de permisos.
No toastees el error de un request a la API — ya está toasteado. Tu catchError es solo para dejar la UI
consistente (results.set([]), total.set(0)).
Sí usá el toast a mano para los éxitos ("Centro médico guardado") y para errores de validación locales.
Gotchas
ok(data, totalRows) ignora totalRows si es 0La implementación hace if (totalRows). Con cero resultados el campo no viaja: en el front tratá
res.totalRows ?? 0.
errorMessage()ponedata = null. Y el interceptor del front no toastea si haydata— así que un error con payload no muestra toast. Es intencional para los errores de límite y de validación.error(e)guarda elErrorenerr, y unErrorserializa a{}en JSON. El front recibe{"success":false,"err":{}}sin ninguna pista. El mensaje real está en la consola del servidor. Cuando escribas uncatch, completá tambiénmessagepara que la próxima falla se vea en la respuesta.endTransactiondecide commit o rollback leyendoresponse.success. Si te olvidás de setear elresponseantes delfinally, rollbackea trabajo bueno en silencio.- El whitelist del
ORDER BYdescarta en silencio los campos que no mapea: si el orden "no anda", probablemente el campo no esté en el mapeo. No es un bug de la tabla.