Appearance
API Endpoints
< Anterior: Base de Datos | Indice | Siguiente: Handlers >
Documentacion retrospectiva - Generada a partir de codigo implementado el 2026-02-24. Fuente:
Routes/Jobs/JobRoutes.php,Routes/Jobs/AdminJobRoutes.php, controllers y middleware.
Tabla de Contenidos
- Ruta Base
- Autenticacion
- Endpoints de Usuario (JWT)
- Endpoints Admin (JWT + permiso admin)
- Endpoints de Monitoring (Bearer METRICS_SECRET)
- Flujos de Ejecucion
Ruta Base
Todos los endpoints del sistema de background jobs utilizan el prefijo:
/backend/jobsEste prefijo se registra en index.php y las rutas se definen en Routes/Jobs/JobRoutes.php.
Autenticacion
El sistema utiliza tres mecanismos de autenticacion segun el tipo de endpoint:
| Grupo | Mecanismo | Header |
|---|---|---|
| Usuario | JWT (middleware global) | Authorization: Bearer {jwt} |
| Admin | JWT + verificacion de permiso admin | Authorization: Bearer {jwt} |
| Monitoring | Bearer token estatico | Authorization: Bearer {METRICS_SECRET} |
Propagacion de contexto multi-tenant
Los campos nro_sistema y prueba se propagan automaticamente desde el JWT y el ConnectionMiddleware en los endpoints de usuario. No se envian como parametros manuales:
nro_sistema: extraido dePayload->sistema(JWT)prueba: determinado porConnectionManager::isPruebaConnection()db: derivado dePayload->dbcon sufijo_psi es modo pruebaschema: extraido dePayload->schema(X-Schema header)
Endpoints de Usuario (JWT)
POST /backend/jobs/{type}
Descripcion: Despachar nuevo job para ejecucion asincrona.
Controller: JobController::dispatch()
Path Params:
type(string): Tipo de job (debe tener handler registrado)
Headers:
Authorization: Bearer {jwt}(requerido)X-Schema: {schema}(requerido)
Request Body:
json
{
"payload": {
"campo1": "valor",
"campo2": 123
},
"schema_level": "sucursal"
}El campo schema_level es opcional (default: "sucursal"). El controller inyecta automaticamente un bloque _context dentro del payload con datos de identidad del JWT (cuit, nro_cliente, id_usuario, id, sistema, schema_level, prueba) para que el worker CLI pueda reconstruir el contexto sin JWT.
Response (202 Accepted):
json
{
"status": "success",
"data": {
"status": "accepted",
"job_id": 123,
"message": "Job creado, se ejecutará en segundo plano."
}
}Codigos de respuesta:
- 202 Accepted: Job creado exitosamente
- 400 Bad Request: Payload invalido
- 422 Unprocessable Entity: Tipo de job no registrado (
InvalidJobTypeException) - 429 Too Many Requests: Usuario excede limite de jobs pendientes (
TooManyJobsException)
Ejemplo con curl:
bash
curl -X POST http://localhost/backend/jobs/batch_invoicing \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc..." \
-H "X-Schema: suc0001" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"cliente_ids": [1, 2, 3],
"fecha": "2026-02-05"
}
}'GET /backend/jobs/{id}
Descripcion: Consultar estado de un job. Solo el usuario propietario puede consultarlo (owner check por user_id).
Controller: JobController::getStatus()
Path Params:
id(int): ID del job
Headers:
Authorization: Bearer {jwt}(requerido)
Response (200 OK):
json
{
"status": "success",
"data": {
"id": 123,
"type": "batch_invoicing",
"status": "completed",
"result": {
"facturas_creadas": 3,
"monto_total": 3000.00,
"factura_ids": [101, 102, 103],
"errores": []
},
"error": null,
"created_at": "2026-02-05T10:00:00Z",
"started_at": "2026-02-05T10:00:05Z",
"completed_at": "2026-02-05T10:05:30Z",
"execution_time_seconds": 325
}
}Campos del response (segun JobController::getStatus()):
id,type,status,result,errorcreated_at,started_at,completed_atexecution_time_seconds: calculado porBackgroundJob::getExecutionTime()
Estados posibles:
pending: Job en cola, aun no iniciorunning: Job ejecutandose actualmentecompleted: Job termino exitosamente (verresult)failed: Job fallo (vererror)
Codigos de respuesta:
- 200 OK: Job encontrado
- 404 Not Found: Job no existe o no pertenece al usuario
GET /backend/jobs/active/{type}
Descripcion: Verificar si existe un job activo (pending o running) del tipo dado para el scope del tenant autenticado (db + schema raiz). Usado por el frontend antes de despachar un nuevo job para prevenir duplicados.
Controller: JobController::getActive()
Path Params:
type(string): Tipo de job a consultar
Headers:
Authorization: Bearer {jwt}(requerido)X-Schema: {schema}(requerido)
Contexto multi-tenant: filtra por db y root_schema derivados del JWT y ConnectionManager. El root_schema se extrae del schema del usuario (ej: suc0001caja0001 → suc0001) para que un job activo en cualquier caja de la sucursal sea visible.
Response (200 OK — job activo):
json
{
"status": "success",
"data": {
"active": true,
"job_id": 123
}
}Response (200 OK — sin job activo):
json
{
"status": "success",
"data": {
"active": false
}
}Nota: job_id solo se incluye cuando active es true. El endpoint es por-usuario para la consulta de ownership, pero la guardia de dedup en JobDispatcher::dispatch() opera a nivel de scope (type, db, root_schema). Un usuario puede recibir active: false aqui y aun asi recibir un 409 al intentar despachar si otro usuario del mismo scope tiene un job activo.
Codigos de respuesta:
- 200 OK: Consulta exitosa (independientemente de si hay job activo)
- 401 Unauthorized: JWT invalido o ausente
GET /backend/jobs/{id}/stream
Descripcion: SSE endpoint para recibir actualizaciones en tiempo real via PostgreSQL LISTEN/NOTIFY.
Controller: JobStreamController::stream()
Path Params:
id(int): ID del job
Headers:
Authorization: Bearer {jwt}(requerido, va por middleware global de Slim)
Response: Server-Sent Events (SSE)
Headers de respuesta:
Content-Type: text/event-streamCache-Control: no-cacheX-Accel-Buffering: no(desactiva buffering nginx)Connection: keep-alive
Comportamiento:
- Job ya terminado: si el job esta en estado
completedofailed, retorna un unico eventostatus_changedy cierra la conexion (sin abrir LISTEN). - Job activo: abre conexion SSE con PostgreSQL
LISTEN "job_updates_{schema}_{id}", envia estado actual inmediatamente, luego queda en loop esperando notificaciones. - Heartbeat: cada ~20 segundos envia un comentario SSE (
: heartbeat) para mantener la conexion viva. - Timeout: la conexion se cierra despues de 300 segundos (configurable con
JOB_STREAM_MAX_SECONDSen.env).
Event types:
status_changed: Cuando el status del job cambia
event: status_changed data: {"id": 123, "status": "running", "progress": 0, "result": null, "error": null}progress_updated: Cuando el progreso cambia (progress < 100)
event: progress_updated data: {"id": 123, "status": "running", "progress": 45.5}error: Error al conectar al stream
event: error data: {"message": "Error al conectar al stream"}
Canal PostgreSQL: job_updates_{schema}_{id} (incluye schema para evitar colision cross-tenant, ya que los IDs auto-increment pueden coincidir entre schemas).
Nota sobre uso en frontend: El endpoint SSE esta implementado y funcional en backend. Sin embargo, el frontend actualmente usa polling (GET /backend/jobs/{id}) debido a limitaciones de autenticacion cross-domain con EventSource (no soporta headers custom). El endpoint SSE queda disponible para clientes que puedan autenticarse por el middleware JWT global (mismo dominio).
GET /backend/jobs/notifications
Descripcion: Listar notificaciones no leidas del usuario autenticado.
Controller: JobController::getNotifications()
Headers:
Authorization: Bearer {jwt}(requerido)
Contexto multi-tenant: filtra por nro_sistema, user_id, db, schema y prueba, todos extraidos automaticamente del JWT y ConnectionManager.
Response (200 OK):
json
{
"status": "success",
"data": [
{
"id": 456,
"type": "success",
"title": "Facturacion completada",
"message": "Se crearon 50 facturas exitosamente",
"metadata": {
"job_id": 123,
"result": { "facturas_creadas": 50 }
},
"created_at": "2026-02-05T10:05:30Z"
}
]
}Campos del response (segun controller): id, type, title, message, metadata, created_at. No incluye is_read ni read_at porque solo retorna notificaciones no leidas.
PATCH /backend/jobs/notifications/{id}/read
Descripcion: Marcar una notificacion como leida.
Controller: JobController::markNotificationRead()
Path Params:
id(int): ID de la notificacion
Headers:
Authorization: Bearer {jwt}(requerido)
Contexto multi-tenant: valida ownership por nro_sistema, user_id, db, schema y prueba.
Response exitosa (200 OK):
json
{
"status": "success"
}Response error (404):
json
{
"status": "error",
"message": "Notificación no encontrada."
}Nota: A diferencia de lo documentado anteriormente, la respuesta exitosa es 200 (no 204), ya que retorna un body JSON con {"status": "success"}.
Endpoints Admin (JWT + permiso admin)
Los endpoints de administracion estan agrupados bajo /backend/jobs/admin/ y definidos en Routes/Jobs/AdminJobRoutes.php.
Autenticacion: JWT del usuario + verificacion de permiso admin via AdminPermissionChecker. Actualmente (2026-02-24) el check de permisos esta temporalmente deshabilitado (isAdmin() retorna true para todos los usuarios autenticados) pendiente del semillado de rel_grupos_usuarios.
Controller: AdminJobController
GET /backend/jobs/admin/failed
Descripcion: Lista jobs en estado failed con paginacion.
Query Params:
page(int, default: 1): Numero de paginaper_page(int, default: 20, max: 100): Resultados por paginaschema(string, opcional): Filtrar por schema especifico
Response (200 OK):
json
{
"status": "success",
"data": {
"jobs": [
{
"id": 45,
"type": "batch_invoicing",
"user_id": 12,
"db": "empresa_xyz",
"schema": "suc0001",
"status": "failed",
"payload": { "cliente_ids": [1, 2] },
"result": null,
"error": "Cliente ID 999 no encontrado",
"progress": 50.0,
"retry_count": 2,
"max_retries": 3,
"next_retry_at": null,
"created_at": "2026-02-05T10:00:00Z",
"started_at": "2026-02-05T10:00:05Z",
"completed_at": "2026-02-05T10:02:30Z",
"execution_time_seconds": 145
}
],
"total": 3,
"page": 1,
"per_page": 20
}
}Campos de job admin (segun AdminJobController::serializeJob()): incluye campos adicionales respecto al endpoint de usuario: user_id, db, schema, progress, retry_count, max_retries, next_retry_at.
Codigos de respuesta:
- 200 OK: Lista retornada
- 403 Forbidden: Usuario sin permisos de administrador
GET /backend/jobs/admin/{id}
Descripcion: Obtener un job por ID, sin importar su estado ni propietario (vista admin).
Path Params:
id(int): ID del job
Response (200 OK):
json
{
"status": "success",
"data": {
"id": 45,
"type": "batch_invoicing",
"user_id": 12,
"db": "empresa_xyz",
"schema": "suc0001",
"status": "failed",
"payload": { "cliente_ids": [1, 2] },
"result": null,
"error": "Cliente ID 999 no encontrado",
"progress": 50.0,
"retry_count": 2,
"max_retries": 3,
"next_retry_at": null,
"created_at": "2026-02-05T10:00:00Z",
"started_at": "2026-02-05T10:00:05Z",
"completed_at": "2026-02-05T10:02:30Z",
"execution_time_seconds": 145
}
}Codigos de respuesta:
- 200 OK: Job encontrado
- 400 Bad Request: ID invalido (menor o igual a 0)
- 403 Forbidden: Usuario sin permisos de administrador
- 404 Not Found: Job no existe
POST /backend/jobs/admin/{id}/retry
Descripcion: Reintentar manualmente un job fallido. Resetea el job a estado pending y lanza un nuevo worker CLI.
Path Params:
id(int): ID del job
Logica de negocio:
- Solo se pueden reintentar jobs en estado
failed(previene doble ejecucion en jobs running) - Resetea el job via
JobRepository::resetForManualRetry() - Lanza el worker CLI directamente:
php cli/background-worker.php {jobId} {schema} {db}
Response exitosa (200 OK):
json
{
"status": "success",
"message": "Job programado para reintento"
}Codigos de respuesta:
- 200 OK: Job programado para reintento
- 400 Bad Request: ID invalido
- 403 Forbidden: Usuario sin permisos de administrador
- 404 Not Found: Job no existe
- 422 Unprocessable Entity: Job no esta en estado
failed
DELETE /backend/jobs/admin/{id}
Descripcion: Eliminar un job permanentemente (hard delete, no soft delete).
Path Params:
id(int): ID del job
Logica: Ejecuta DELETE FROM background_jobs WHERE id = :id en la conexion ini (base de datos de configuracion/empresa).
Response exitosa (200 OK):
json
{
"status": "success"
}Codigos de respuesta:
- 200 OK: Job eliminado
- 400 Bad Request: ID invalido
- 403 Forbidden: Usuario sin permisos de administrador
- 404 Not Found: Job no existe
Endpoints de Monitoring (Bearer METRICS_SECRET)
Los endpoints de monitoreo utilizan autenticacion por token estatico via MetricsAuthMiddleware, no JWT. Esto permite el scraping externo desde herramientas como Prometheus o load balancers.
Autenticacion: Authorization: Bearer {METRICS_SECRET} donde METRICS_SECRET se configura en .env.
Comportamiento del middleware:
- Si
METRICS_SECRETno esta configurado en.env: retorna 503 Service Unavailable - Si el header no coincide: retorna 401 Unauthorized
- Si coincide: permite el acceso al endpoint
Controller: MonitoringController
GET /backend/jobs/health
Descripcion: Health check del sistema de background jobs. Retorna estado de salud en JSON.
Response (200 OK - healthy/degraded):
json
{
"status": "healthy",
"timestamp": "2026-02-24T15:30:00+00:00",
"checks": {
"database": {
"status": "up",
"latency_ms": 3
},
"queue": {
"status": "healthy",
"pending_count": 12,
"threshold": 1000
},
"stale_jobs": {
"status": "healthy",
"count": 0
}
}
}Response (503 Service Unavailable - unhealthy):
json
{
"status": "unhealthy",
"timestamp": "2026-02-24T15:30:00+00:00",
"checks": {
"database": {
"status": "down",
"latency_ms": 0
},
"queue": {
"status": "unhealthy",
"pending_count": 6000,
"threshold": 1000
},
"stale_jobs": {
"status": "warning",
"count": 3
}
}
}Health checks realizados (implementados en HealthChecker):
| Check | Que verifica | Umbrales |
|---|---|---|
database | Conectividad y latencia (SELECT 1 en conexion principal) | up / down |
queue | Cantidad de jobs pendientes | healthy < 1000, degraded 1000-5000, unhealthy > 5000 |
stale_jobs | Jobs en running por mas de 60 minutos | healthy = 0, warning > 0 |
Estado overall:
healthy: todos los checks pasandegraded: al menos un check estadegradedowarningunhealthy: al menos un check estadownounhealthy
HTTP Status: 200 si healthy o degraded, 503 si unhealthy.
GET /backend/jobs/metrics
Descripcion: Metricas en formato Prometheus text format para scraping automatizado.
Response (200 OK):
Content-Type: text/plain; version=0.0.4# HELP background_jobs_pending Number of pending background jobs
# TYPE background_jobs_pending gauge
background_jobs_pending 12
# HELP background_jobs_running Number of running background jobs
# TYPE background_jobs_running gauge
background_jobs_running 3
# HELP background_jobs_completed_total Total completed background jobs
# TYPE background_jobs_completed_total counter
background_jobs_completed_total 1450
# HELP background_jobs_failed_total Total failed background jobs
# TYPE background_jobs_failed_total counter
background_jobs_failed_total 23
# HELP background_jobs_retryable Total retryable (failed but not exhausted) jobs
# TYPE background_jobs_retryable gauge
background_jobs_retryable 5
# HELP background_jobs_avg_execution_seconds Average job execution time in seconds
# TYPE background_jobs_avg_execution_seconds gauge
background_jobs_avg_execution_seconds 12.50Metricas expuestas:
| Metrica | Tipo | Descripcion |
|---|---|---|
background_jobs_pending | gauge | Jobs pendientes en cola |
background_jobs_running | gauge | Jobs ejecutandose actualmente |
background_jobs_completed_total | counter | Total historico de jobs completados |
background_jobs_failed_total | counter | Total historico de jobs fallidos |
background_jobs_retryable | gauge | Jobs fallidos que aun no agotaron reintentos |
background_jobs_avg_execution_seconds | gauge | Tiempo promedio de ejecucion en segundos |
Flujos de Ejecucion
Despacho Asincrono (POST /backend/jobs/{type})
Actores:
- Usuario (Frontend)
- JobController
- JobDispatcher
- JobRepository
- OS (exec)
Flujo:
Request HTTP:
httpPOST /backend/jobs/batch_invoicing X-Schema: suc0001 Authorization: Bearer {jwt} { "payload": { "cliente_ids": [1, 2, 3], "fecha": "2026-02-05" } }JobController::dispatch():
- Extrae
user_id,schema,nro_sistemadel JWT (Payload) - Determina
pruebaviaConnectionManager::isPruebaConnection() - Deriva
dbcon sufijo_psi es modo prueba - Inyecta
_contexten el payload con datos de identidad - Delega a
JobDispatcher::dispatch()
- Extrae
JobDispatcher::dispatch():
- Verifica que el tipo de job tenga handler registrado
- Verifica limite de jobs pendientes por usuario
- Crea
BackgroundJobconnro_sistemayprueba - Persiste en BD via
JobRepository - Lanza worker CLI:
php cli/background-worker.php {jobId} {schema} {db}
Response HTTP 202 Accepted:
json{ "status": "success", "data": { "status": "accepted", "job_id": 123, "message": "Job creado, se ejecutará en segundo plano." } }
Tiempo total: ~50-200ms (NO espera al worker)
Ejecucion en Background (CLI Worker)
Actores:
- background-worker.php (proceso independiente)
- JobExecutor
- Handler (ej: BatchInvoicingJobHandler)
- Service (ej: FacturaService)
- NotificationService
Flujo:
- Inicio del worker:
php cli/background-worker.php {jobId} {schema} {db} - Carga el job desde BD y verifica existencia
- Actualiza a running:
status = 'running',started_at = NOW() - Configura schema (multi-tenant): establece
search_pathcorrecto - Obtiene handler: busca handler registrado para
job.type - Ejecuta handler:
$handler->handle($payload)dentro de try/catch - Actualiza job final:
completedofailedconresultoerror - Crea notificacion: via
NotificationService::createFromJobResult() - Exit: codigo 0 si exitoso, 1 si fallo
Tiempo total: Variable (segundos a minutos, dependiendo del handler)
Consulta de Estado (Polling HTTP)
El frontend usa polling contra GET /backend/jobs/{id} con intervalo de ~2 segundos:
javascript
async function pollJobStatus(jobId) {
const interval = 2000; // 2 segundos
const poll = setInterval(async () => {
const response = await fetch(`/backend/jobs/${jobId}`);
const data = await response.json();
if (data.data.status === 'completed') {
console.log('Job completado:', data.data.result);
clearInterval(poll);
}
if (data.data.status === 'failed') {
console.error('Job fallo:', data.data.error);
clearInterval(poll);
}
}, interval);
}Ventajas:
- Simple de implementar
- Compatible con todos los navegadores
- No requiere conexion persistente
- Funciona cross-domain sin limitaciones de auth
Desventajas:
- Latencia: usuario espera hasta proximo poll
- Overhead: requests aunque job no haya cambiado
SSE con PostgreSQL NOTIFY (Backend implementado)
El endpoint GET /backend/jobs/{id}/stream esta completamente implementado en backend con JobStreamController. Utiliza pgsqlGetNotify() para escuchar el canal job_updates_{schema}_{id}.
El frontend no lo utiliza actualmente por limitaciones de EventSource que no soporta headers custom de autorizacion. Queda disponible para uso futuro o clientes que puedan autenticarse via el middleware JWT global (mismo dominio).
NOTA IMPORTANTE: Esta documentacion fue generada a partir del codigo implementado. Validar con stakeholders antes de considerar final. Ultima verificacion contra codigo: 2026-02-24.