Skip to content

Scheduled Jobs

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.


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()

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)
}
}
}

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étodoFirmaDescripció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?) => voidInvalida la caché de isEnabled. Sin argumento, limpia toda la caché. Se llama automáticamente al actualizar un job desde el admin.

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') // boolean
this.registry.listRegistered() // string[]

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:

Terminal window
pnpm --filter api ts-node prisma/seeds/scheduled-jobs.seed.ts
# o si el script está configurado:
pnpm db:seed:jobs

Categorías disponibles: engagement | reengagement | corporate | billing | ai | content | creators | earnings | maintenance


Todos los cron jobs registrados en el proyecto (fuente: scheduled-jobs.seed.ts + código):

SlugNombreCron (UTC)CategoríaDescripción
streak-risk-emailStreak Risk Email0 18 * * * (diario 18:00)engagementNotifica usuarios con racha en riesgo sin actividad hoy
inactivity-nudgeInactivity Nudge0 10 * * * (diario 10:00)reengagementNotifica usuarios sin actividad >3 días
inactivity-nudge-extendedInactivity Nudge Extended30 10 * * * (diario 10:30)reengagementEscalado de severidad a 7/14/30 días
plan-renewal-reminderPlan Renewal Reminder0 10 * * * (diario 10:00)billingAvisa a usuarios B2C 3 días antes de renovación
weekly-digestWeekly Digest B2B0 8 * * 1 (lunes 08:00)corporateResumen semanal de actividad de equipo para corp admins
monthly-reportMonthly Report B2B0 1 1 * * (día 1 mes 01:00)corporateInforme mensual para corp admins
credential-expiry-checkAI Credential Expiry Check0 6 * * * (diario 06:00)aiVerifica API keys de proveedores IA
course-deadline-reminderCourse Deadline Reminder0 9 * * * (diario 09:00)corporateAvisa alumnos a 7/1 días del vencimiento de rutas asignadas
quiz-retry-notifierQuiz Retry Notifier*/15 * * * * (cada 15 min)contentDetecta cooldowns de quiz expirados
member-inactivity-checkerMember Inactivity Checker30 6 * * * (diario 06:30)corporateDetecta miembros de org sin actividad >21 días
creator-weekly-statsCreator Weekly Stats30 8 * * 1 (lunes 08:30)creatorsDigest semanal para creadores con actividad reciente
trial-expiry-corpTrial Expiry Notifier30 9 * * * (diario 09:30)corporateNotifica a corp admin y super admin antes del vencimiento del trial
path-review-overduePath Review Overdue30 10 * * * (diario 10:30)contentDetecta rutas en revisión sin respuesta admin >3 días
evaluate-creator-badgesCreator Badge Evaluator30 2 * * * (diario 02:30)creatorsEvalúa condiciones de badges para todos los creadores
earnings-calculatorCreator Earnings Preview15 3 * * * (diario 03:15)earningsPreview diario de ingresos estimados por creador
earnings-closureCreator Earnings Closure0 6 2 * * (día 2 mes 06:00)earningsCierre mensual de ingresos (bloquea el período)
cleanup-old-notificationsCleanup Old Notifications0 3 * * 0 (domingo 03:00)maintenanceElimina notificaciones leídas de más de 90 días

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 lanza 409 en ese caso.
  • Ver el historial de ejecuciones de cada job (últimas 20 por defecto, hasta 100).
GET /api/admin/jobs SUPER_ADMIN Lista todos los jobs con sus últimas 5 ejecuciones
GET /api/admin/jobs/:slug SUPER_ADMIN Detalle de un job con las últimas 10 ejecuciones
GET /api/admin/jobs/:slug/status SUPER_ADMIN Estado actual (isRunning, lastRunAt, nextRunAt)
GET /api/admin/jobs/:slug/executions SUPER_ADMIN Historial paginado de ejecuciones (máx. 100)
PATCH /api/admin/jobs/:slug SUPER_ADMIN Actualiza cronExpr, timezone, isEnabled, config, notes
POST /api/admin/jobs/:slug/run SUPER_ADMIN Lanza el job manualmente (409 si ya está corriendo)

CampoTipoDescripción
slugString (unique)Identificador inmutable del job
nameStringNombre legible
descriptionString?Qué hace el job
categoryStringCategoría (engagement, billing, etc.)
cronExprStringExpresión cron activa (editable por admin)
cronDefaultStringExpresión original del seed (para “reset”)
timezoneStringZona horaria (default UTC)
isEnabledBooleanSi el job debe ejecutarse
retentionDaysIntDías que se conservan ejecuciones
configJson?Parámetros configurables
lastRunAtDateTime?Timestamp de la última ejecución
lastStatusJobStatus?Resultado de la última ejecución
lastDurationMsInt?Duración en milisegundos
lastItemsCountInt?Elementos procesados en la última ejecución
nextRunAtDateTime?Próxima ejecución prevista (calculado externamente)
CampoTipoDescripción
jobIdStringFK a ScheduledJob
statusJobStatusRUNNING | COMPLETED | FAILED | SKIPPED
startedAtDateTimeInicio de la ejecución
finishedAtDateTime?Fin de la ejecución
durationMsInt?Duración total en ms
itemsProcessedInt?Registros procesados
itemsSummaryString?Resumen textual
errorMessageString?Mensaje de error si FAILED
triggeredByStringcron o manual:<adminId>

SituaciónComportamiento
Job desactivado por adminisEnabledfalse → retorna sin ejecutar
BD no disponible en isEnabledFail-open: el job ejecuta igualmente
BD no disponible en startExecutionDevuelve null; el job ejecuta sin registrar
completeExecution/failExecution con ctx = nullNo-op — nunca bloquea la lógica
Admin lanza “Run Now” mientras el job ya correScheduledJobsService lanza 409 ConflictException
Admin lanza “Run Now”Se crea JobExecution(RUNNING) antes de llamar el handler; startExecution consume ese contexto (sin duplicar fila)