Scheduled Jobs
¿Qué es?
Section titled “¿Qué es?”El sistema de Scheduled Jobs permite ejecutar cron jobs en NestJS con visibilidad completa para el SUPER_ADMIN desde /admin/platform/jobs. Cada job:
- Puede activarse o desactivarse desde la UI sin deploys.
- Registra cada ejecución (inicio, duración, resultado, elementos procesados) en BD.
- Puede lanzarse manualmente desde el panel admin con el botón “Run Now”.
- Tiene su expresión cron configurable por el admin sin tocar el código.
La infraestructura vive en apps/api/src/modules/scheduled-jobs/. El módulo es no-global: cada módulo que aloje jobs debe importarlo explícitamente.
Diagrama
Section titled “Diagrama”sequenceDiagram participant Cron as NestJS @Cron() participant Tracker as JobExecutionTrackerService participant Registry as ScheduledJobsHandlerRegistry participant DB as Neon PostgreSQL participant Admin as Admin UI
Cron->>Tracker: isEnabled(slug) Tracker->>DB: SELECT isEnabled FROM ScheduledJob WHERE slug=? DB-->>Tracker: true / false alt Desactivado Tracker-->>Cron: false → return sin ejecutar else Activo Cron->>Tracker: startExecution(slug, 'cron') Tracker->>DB: INSERT JobExecution(RUNNING) Tracker-->>Cron: ExecutionContext { id, jobId, startedAt } Cron->>Cron: lógica de negocio... Cron->>Tracker: completeExecution(ctx, slug, { itemsProcessed }) Tracker->>DB: UPDATE JobExecution(COMPLETED) + UPDATE ScheduledJob(lastRunAt) end
Admin->>Registry: "Run Now" → trigger(slug) Registry->>Cron: handler()Patrón obligatorio
Section titled “Patrón obligatorio”Todo cron job nuevo debe seguir este patrón exacto. No hay excepciones.
import { Injectable, OnModuleInit } from '@nestjs/common'import { Cron } from '@nestjs/schedule'import { JobExecutionTrackerService } from '../scheduled-jobs/job-execution-tracker.service'import { ScheduledJobsHandlerRegistry } from '../scheduled-jobs/scheduled-jobs-handler.registry'
const SLUG = 'mi-nuevo-job' // kebab-case, único, inmutable
@Injectable()export class MiNuevoJob implements OnModuleInit { constructor( private readonly tracker: JobExecutionTrackerService, private readonly registry: ScheduledJobsHandlerRegistry, // ...otros servicios que necesite el job ) {}
onModuleInit() { this.registry.register(SLUG, () => this.run()) }
@Cron('0 4 * * *') async run(): Promise<void> { if (!(await this.tracker.isEnabled(SLUG))) return
const ctx = await this.tracker.startExecution(SLUG, 'cron') try { // lógica del job... const itemsProcessed = 42
await this.tracker.completeExecution(ctx, SLUG, { itemsProcessed, itemsSummary: `Procesados ${itemsProcessed} registros`, }) } catch (err) { await this.tracker.failExecution(ctx, SLUG, (err as Error).message) } }}JobExecutionTrackerService
Section titled “JobExecutionTrackerService”Archivo: apps/api/src/modules/scheduled-jobs/job-execution-tracker.service.ts
Servicio ligero que registra el ciclo de vida de cada ejecución. Se exporta desde ScheduledJobsModule.
| Método | Firma | Descripción |
|---|---|---|
isEnabled | (slug: string) => Promise<boolean> | Consulta BD con caché de 1 minuto (TTL). Fail-open: devuelve true si la BD no responde. |
startExecution | (slug, triggeredBy?) => Promise<ExecutionContext | null> | Crea JobExecution(RUNNING) en BD. Si el admin lanzó “Run Now”, reutiliza el contexto pendiente (sin duplicar fila). Devuelve null si la BD falla — el job sigue ejecutando. |
completeExecution | (ctx, slug, { itemsProcessed?, itemsSummary? }) => Promise<void> | Actualiza JobExecution(COMPLETED) y el resumen en ScheduledJob. No-op si ctx es null. |
failExecution | (ctx, slug, errorMessage) => Promise<void> | Actualiza JobExecution(FAILED) con el mensaje de error. No-op si ctx es null. |
invalidateCache | (slug?) => void | Invalida la caché de isEnabled. Sin argumento, limpia toda la caché. Se llama automáticamente al actualizar un job desde el admin. |
ScheduledJobsHandlerRegistry
Section titled “ScheduledJobsHandlerRegistry”Archivo: apps/api/src/modules/scheduled-jobs/scheduled-jobs-handler.registry.ts
Mapa en memoria de slug → handler. Permite al admin lanzar jobs manualmente con “Run Now”.
// Registro (en onModuleInit del job)this.registry.register('mi-slug', () => this.run())
// Consulta (ScheduledJobsService lo usa internamente)this.registry.hasHandler('mi-slug') // booleanthis.registry.listRegistered() // string[]Añadir el seed
Section titled “Añadir el seed”Cada nuevo job necesita un registro en BD para que el panel admin lo muestre. Añadir una entrada al array en apps/api/prisma/seeds/scheduled-jobs.seed.ts:
{ slug: 'mi-nuevo-job', // kebab-case, único e inmutable name: 'Mi Nuevo Job', description: 'Qué hace este job.', category: 'maintenance', // ver categorías disponibles abajo icon: '🔧', cronExpr: '0 4 * * *', // expresión inicial cronDefault: '0 4 * * *', // valor de "reset" en el admin retentionDays: 30, // días que se conservan las ejecuciones config: {}, // configuración opcional (JSON)}El upsert del seed nunca sobreescribe cronExpr, isEnabled, config ni notes — esos son campos del admin. Ejecutar el seed:
pnpm --filter api ts-node prisma/seeds/scheduled-jobs.seed.ts# o si el script está configurado:pnpm db:seed:jobsCategorías disponibles: engagement | reengagement | corporate | billing | ai | content | creators | earnings | maintenance
Jobs activos
Section titled “Jobs activos”Todos los cron jobs registrados en el proyecto (fuente: scheduled-jobs.seed.ts + código):
| Slug | Nombre | Cron (UTC) | Categoría | Descripción |
|---|---|---|---|---|
streak-risk-email | Streak Risk Email | 0 18 * * * (diario 18:00) | engagement | Notifica usuarios con racha en riesgo sin actividad hoy |
inactivity-nudge | Inactivity Nudge | 0 10 * * * (diario 10:00) | reengagement | Notifica usuarios sin actividad >3 días |
inactivity-nudge-extended | Inactivity Nudge Extended | 30 10 * * * (diario 10:30) | reengagement | Escalado de severidad a 7/14/30 días |
plan-renewal-reminder | Plan Renewal Reminder | 0 10 * * * (diario 10:00) | billing | Avisa a usuarios B2C 3 días antes de renovación |
weekly-digest | Weekly Digest B2B | 0 8 * * 1 (lunes 08:00) | corporate | Resumen semanal de actividad de equipo para corp admins |
monthly-report | Monthly Report B2B | 0 1 1 * * (día 1 mes 01:00) | corporate | Informe mensual para corp admins |
credential-expiry-check | AI Credential Expiry Check | 0 6 * * * (diario 06:00) | ai | Verifica API keys de proveedores IA |
course-deadline-reminder | Course Deadline Reminder | 0 9 * * * (diario 09:00) | corporate | Avisa alumnos a 7/1 días del vencimiento de rutas asignadas |
quiz-retry-notifier | Quiz Retry Notifier | */15 * * * * (cada 15 min) | content | Detecta cooldowns de quiz expirados |
member-inactivity-checker | Member Inactivity Checker | 30 6 * * * (diario 06:30) | corporate | Detecta miembros de org sin actividad >21 días |
creator-weekly-stats | Creator Weekly Stats | 30 8 * * 1 (lunes 08:30) | creators | Digest semanal para creadores con actividad reciente |
trial-expiry-corp | Trial Expiry Notifier | 30 9 * * * (diario 09:30) | corporate | Notifica a corp admin y super admin antes del vencimiento del trial |
path-review-overdue | Path Review Overdue | 30 10 * * * (diario 10:30) | content | Detecta rutas en revisión sin respuesta admin >3 días |
evaluate-creator-badges | Creator Badge Evaluator | 30 2 * * * (diario 02:30) | creators | Evalúa condiciones de badges para todos los creadores |
earnings-calculator | Creator Earnings Preview | 15 3 * * * (diario 03:15) | earnings | Preview diario de ingresos estimados por creador |
earnings-closure | Creator Earnings Closure | 0 6 2 * * (día 2 mes 06:00) | earnings | Cierre mensual de ingresos (bloquea el período) |
cleanup-old-notifications | Cleanup Old Notifications | 0 3 * * 0 (domingo 03:00) | maintenance | Elimina notificaciones leídas de más de 90 días |
Panel admin
Section titled “Panel admin”Ruta: /admin/platform/jobs (solo SUPER_ADMIN)
Archivo frontend: apps/web/src/pages/admin/platform/AdminScheduledJobsPage.tsx
Desde la UI el admin puede:
- Ver todos los jobs con su estado actual (RUNNING / COMPLETED / FAILED / nunca ejecutado), última ejecución y duración.
- Activar / desactivar un job individualmente (
isEnabled). El cambio invalida la caché del tracker de inmediato. - Modificar la expresión cron de un job (
cronExpr) y su zona horaria. - Ajustar
config(JSON) — parámetros del job como umbrales o días. - Lanzar manualmente (“Run Now”) —
POST /api/admin/jobs/:slug/run. Detecta si ya hay una ejecución en curso y lanza409en ese caso. - Ver el historial de ejecuciones de cada job (últimas 20 por defecto, hasta 100).
Endpoints REST
Section titled “Endpoints REST”/api/admin/jobs SUPER_ADMIN Lista todos los jobs con sus últimas 5 ejecuciones /api/admin/jobs/:slug SUPER_ADMIN Detalle de un job con las últimas 10 ejecuciones /api/admin/jobs/:slug/status SUPER_ADMIN Estado actual (isRunning, lastRunAt, nextRunAt) /api/admin/jobs/:slug/executions SUPER_ADMIN Historial paginado de ejecuciones (máx. 100) /api/admin/jobs/:slug SUPER_ADMIN Actualiza cronExpr, timezone, isEnabled, config, notes /api/admin/jobs/:slug/run SUPER_ADMIN Lanza el job manualmente (409 si ya está corriendo) Modelo en BD
Section titled “Modelo en BD”ScheduledJob
Section titled “ScheduledJob”| Campo | Tipo | Descripción |
|---|---|---|
slug | String (unique) | Identificador inmutable del job |
name | String | Nombre legible |
description | String? | Qué hace el job |
category | String | Categoría (engagement, billing, etc.) |
cronExpr | String | Expresión cron activa (editable por admin) |
cronDefault | String | Expresión original del seed (para “reset”) |
timezone | String | Zona horaria (default UTC) |
isEnabled | Boolean | Si el job debe ejecutarse |
retentionDays | Int | Días que se conservan ejecuciones |
config | Json? | Parámetros configurables |
lastRunAt | DateTime? | Timestamp de la última ejecución |
lastStatus | JobStatus? | Resultado de la última ejecución |
lastDurationMs | Int? | Duración en milisegundos |
lastItemsCount | Int? | Elementos procesados en la última ejecución |
nextRunAt | DateTime? | Próxima ejecución prevista (calculado externamente) |
JobExecution
Section titled “JobExecution”| Campo | Tipo | Descripción |
|---|---|---|
jobId | String | FK a ScheduledJob |
status | JobStatus | RUNNING | COMPLETED | FAILED | SKIPPED |
startedAt | DateTime | Inicio de la ejecución |
finishedAt | DateTime? | Fin de la ejecución |
durationMs | Int? | Duración total en ms |
itemsProcessed | Int? | Registros procesados |
itemsSummary | String? | Resumen textual |
errorMessage | String? | Mensaje de error si FAILED |
triggeredBy | String | cron o manual:<adminId> |
Comportamiento del tracker ante fallos
Section titled “Comportamiento del tracker ante fallos”| Situación | Comportamiento |
|---|---|
| Job desactivado por admin | isEnabled → false → retorna sin ejecutar |
BD no disponible en isEnabled | Fail-open: el job ejecuta igualmente |
BD no disponible en startExecution | Devuelve null; el job ejecuta sin registrar |
completeExecution/failExecution con ctx = null | No-op — nunca bloquea la lógica |
| Admin lanza “Run Now” mientras el job ya corre | ScheduledJobsService lanza 409 ConflictException |
| Admin lanza “Run Now” | Se crea JobExecution(RUNNING) antes de llamar el handler; startExecution consume ese contexto (sin duplicar fila) |