ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Añadir una tarea programada

template-api
último cambio 2026-09-25

Hay trabajo que no lo dispara nadie: purgar lo que caducó, avisar de lo que toca mañana, precalcular un agregado de madrugada. En este repo eso es @nestjs/schedule a secas — no hay cola, y la que existe en el stack de notificaciones es suya, no de aquí.

Son cuatro decisiones, y ninguna es la expresión cron.

Dónde vive

ScheduleModule.forRoot() se registra en core.module.ts, y las tareas van todas en un TasksModule con sus casos de uso:

@Module({
  providers: [
    SessionCleanupTask,
    SendAppointmentRemindersTask,
    AnonymizeExpiredAccountsTask,
    // El caso de uso y su dependencia se proveen aquí: TasksModule no importa
    // UsersModule, y los repositorios que necesitan sí son globales.
    AnonymizeUserUseCase,
    DeleteUserUseCase,
  ],
})
export class TasksModule {}

Una tarea no es una capa nueva: vive en infrastructure/tasks/ y su trabajo es leer el reloj y llamar a un caso de uso. Si la lógica está dentro del @Cron, no se puede probar sin esperar a las tres de la mañana ni relanzar a mano cuando hace falta.

Decisión 1: la guarda de entorno, y su parseo

Toda tarea empieza igual:

@Cron(CronExpression.EVERY_DAY_AT_3AM, { timeZone: APP_TIME_ZONE })
async run(): Promise<void> {
  if (!this.env.ENABLE_CRON) return;
  // …
}

La guarda existe porque el mismo build corre en varios sitios y sólo una instancia debe ejecutar los crons. Sin ella, cada réplica manda su propio recordatorio y el dueño recibe tres.

Lo que no es obvio es el parseo. Con z.coerce.boolean(), ENABLE_CRON=false se lee como true: Boolean("false") es true, y la variable pensada para apagar los crons los enciende en todas las réplicas. El esquema tiene que trabajar sobre la cadena:

const boolStr = (def: boolean) =>
  z
    .preprocess(emptyToUndefined, z.enum(["true", "false"]).optional())
    .transform((v) => (v === undefined ? def : v === "true"));

ENABLE_CRON: boolStr(false),

Por defecto false: una instancia nueva no empieza a mandar correos porque alguien olvidó una variable.

Decisión 2: la zona horaria va en el decorador

Un @Cron sin timeZone corre a la hora del contenedor, que suele ser UTC. Un recordatorio puesto a las 18:00 sale a las 19:00 o a las 20:00 según el horario de verano, y nadie lo nota hasta que alguien compara el log con el reloj.

export const APP_TIME_ZONE = "Europe/Madrid";

Se pasa a cada decorador y se comparte con el formateador de fechas de los correos, para que una hora signifique lo mismo en los dos sitios.

Y no con TZ en el contenedor, que es la tentación: eso cambia la zona de todo el proceso —logs, cualquier new Date() que se formatee sin zona explícita— para arreglar cuatro decoradores.

Cuidado con el efecto colateral: fijar la hora del cron no arregla las ventanas que el propio código calcula. Si la tarea hace setUTCHours(0,0,0,0) para decir “mañana”, sigue siendo el día en UTC, no el día local, y las citas de la primera hora de la madrugada caen fuera. Son dos arreglos distintos.

Decisión 3: idempotente, no “cuidado con relanzarla”

Un cron se salta noches —el contenedor estaba reiniciándose— y se repite —alguien lo relanza a mano tras desplegar—. Las dos cosas van a pasar, así que la escritura tiene que aguantarlas:

Decisión 4: separar run() de lo que hace

@Cron(CronExpression.EVERY_DAY_AT_3AM, { timeZone: APP_TIME_ZONE })
async run(): Promise<void> {
  if (!this.env.ENABLE_CRON) return;
  await this.refresh();
}

/** Separado de `run()` para poder lanzarlo a mano tras desplegar. */
async refresh(months = REFRESH_MONTHS): Promise<number> { … }

run() es el cron con su guarda; el método de debajo acepta parámetros y es el que se relanza con una ventana mayor cuando una tabla nace vacía. Ojo con la trampa: tener el método separado no lo hace lanzable. Si no hay endpoint ni script que lo llame, “a mano” no existe — la imagen de producción lleva sólo dist/, sin scripts/ ni tsx. O se añade un endpoint de superadmin, o se acepta que lo rellene el cron en sus siguientes pasadas; lo que no vale es escribir “se lanza a mano” y darlo por resuelto.

Una ventana de dos, no de uno

El patrón aparece en cuanto la tarea agrega algo cuyo estado cambia después de escribirse:

/**
 * Dos y no uno porque `status` cambia DESPUÉS de insertarse la fila: el webhook
 * de entrega mueve un envío a DELIVERED o FAILED cuando le llega el evento, que
 * puede ser al día siguiente.
 */
const REFRESH_MONTHS = 2;

Refrescando sólo el mes en curso, una corrección del día 1 sobre un envío del día 30 no entraría nunca en la fila del mes anterior. Cuesta lo mismo y cierra el agujero.

Decir al arrancar qué está armado

Una tarea apagada por configuración y una que corre y no encuentra nada se leen igual en los logs: silencio. La de retención lo resuelve hablando una vez, al arrancar:

onModuleInit(): void {
  const describe = (days: number, what: string) =>
    days > 0 ? `${what} after ${days} day(s)` : `${what} disabled`;

  this.logger.log(
    `Notification log retention — ${describe(redactDays, "redacting recipients")}, ` +
      `${describe(purgeDays, "deleting rows")}`,
  );
}

Vale la pena en cualquier tarea cuyo comportamiento dependa de una variable de entorno. Es una línea en el visor de logs que responde a “¿esto está funcionando?” sin entrar a mirar el .env del servidor.

Qué probar

Un cron no se prueba esperando. Se instancia la clase y se llama a run():

Qué Por qué
Con ENABLE_CRON: false no toca nada Es la guarda que evita el trabajo duplicado entre réplicas
Dos pasadas seguidas dejan el mismo estado La idempotencia, que es el requisito y no una virtud
Un elemento que falla no detiene el lote Lo contrario deja trabajo sin hacer cada noche que haya una fila mala
Una dependencia caída no rompe la tarea Registra y sale: la noche siguiente lo recalcula
Queda traza de lo que hizo Un cron sin log es un cron del que nadie sabe nada

La ventana horaria en sí —que las 3:00 sean las 3:00— no se prueba: se lee en el decorador.