Skip to content

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

Todos los endpoints del sistema de background jobs utilizan el prefijo:

/backend/jobs

Este 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:

GrupoMecanismoHeader
UsuarioJWT (middleware global)Authorization: Bearer {jwt}
AdminJWT + verificacion de permiso adminAuthorization: Bearer {jwt}
MonitoringBearer token estaticoAuthorization: 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 de Payload->sistema (JWT)
  • prueba: determinado por ConnectionManager::isPruebaConnection()
  • db: derivado de Payload->db con sufijo _p si es modo prueba
  • schema: extraido de Payload->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, error
  • created_at, started_at, completed_at
  • execution_time_seconds: calculado por BackgroundJob::getExecutionTime()

Estados posibles:

  • pending: Job en cola, aun no inicio
  • running: Job ejecutandose actualmente
  • completed: Job termino exitosamente (ver result)
  • failed: Job fallo (ver error)

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: suc0001caja0001suc0001) 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-stream
  • Cache-Control: no-cache
  • X-Accel-Buffering: no (desactiva buffering nginx)
  • Connection: keep-alive

Comportamiento:

  1. Job ya terminado: si el job esta en estado completed o failed, retorna un unico evento status_changed y cierra la conexion (sin abrir LISTEN).
  2. Job activo: abre conexion SSE con PostgreSQL LISTEN "job_updates_{schema}_{id}", envia estado actual inmediatamente, luego queda en loop esperando notificaciones.
  3. Heartbeat: cada ~20 segundos envia un comentario SSE (: heartbeat) para mantener la conexion viva.
  4. Timeout: la conexion se cierra despues de 300 segundos (configurable con JOB_STREAM_MAX_SECONDS en .env).

Event types:

  1. status_changed: Cuando el status del job cambia

    event: status_changed
    data: {"id": 123, "status": "running", "progress": 0, "result": null, "error": null}
  2. progress_updated: Cuando el progreso cambia (progress < 100)

    event: progress_updated
    data: {"id": 123, "status": "running", "progress": 45.5}
  3. 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 pagina
  • per_page (int, default: 20, max: 100): Resultados por pagina
  • schema (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:

  1. Solo se pueden reintentar jobs en estado failed (previene doble ejecucion en jobs running)
  2. Resetea el job via JobRepository::resetForManualRetry()
  3. 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_SECRET no 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):

CheckQue verificaUmbrales
databaseConectividad y latencia (SELECT 1 en conexion principal)up / down
queueCantidad de jobs pendienteshealthy < 1000, degraded 1000-5000, unhealthy > 5000
stale_jobsJobs en running por mas de 60 minutoshealthy = 0, warning > 0

Estado overall:

  • healthy: todos los checks pasan
  • degraded: al menos un check esta degraded o warning
  • unhealthy: al menos un check esta down o unhealthy

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

Metricas expuestas:

MetricaTipoDescripcion
background_jobs_pendinggaugeJobs pendientes en cola
background_jobs_runninggaugeJobs ejecutandose actualmente
background_jobs_completed_totalcounterTotal historico de jobs completados
background_jobs_failed_totalcounterTotal historico de jobs fallidos
background_jobs_retryablegaugeJobs fallidos que aun no agotaron reintentos
background_jobs_avg_execution_secondsgaugeTiempo promedio de ejecucion en segundos

Flujos de Ejecucion

Despacho Asincrono (POST /backend/jobs/{type})

Actores:

  • Usuario (Frontend)
  • JobController
  • JobDispatcher
  • JobRepository
  • OS (exec)

Flujo:

  1. Request HTTP:

    http
    POST /backend/jobs/batch_invoicing
    X-Schema: suc0001
    Authorization: Bearer {jwt}
    
    {
      "payload": {
        "cliente_ids": [1, 2, 3],
        "fecha": "2026-02-05"
      }
    }
  2. JobController::dispatch():

    • Extrae user_id, schema, nro_sistema del JWT (Payload)
    • Determina prueba via ConnectionManager::isPruebaConnection()
    • Deriva db con sufijo _p si es modo prueba
    • Inyecta _context en el payload con datos de identidad
    • Delega a JobDispatcher::dispatch()
  3. JobDispatcher::dispatch():

    • Verifica que el tipo de job tenga handler registrado
    • Verifica limite de jobs pendientes por usuario
    • Crea BackgroundJob con nro_sistema y prueba
    • Persiste en BD via JobRepository
    • Lanza worker CLI: php cli/background-worker.php {jobId} {schema} {db}
  4. 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:

  1. Inicio del worker: php cli/background-worker.php {jobId} {schema} {db}
  2. Carga el job desde BD y verifica existencia
  3. Actualiza a running: status = 'running', started_at = NOW()
  4. Configura schema (multi-tenant): establece search_path correcto
  5. Obtiene handler: busca handler registrado para job.type
  6. Ejecuta handler: $handler->handle($payload) dentro de try/catch
  7. Actualiza job final: completed o failed con result o error
  8. Crea notificacion: via NotificationService::createFromJobResult()
  9. 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.


< Anterior: Base de Datos | Indice | Siguiente: Handlers >