import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { DkvService } from './dkv.service'; /** * CronJob constructor — resolved at runtime via require() because `cron` is a * transitive dependency of @nestjs/schedule (not a direct api dep under pnpm * strict isolation, so `import { CronJob } from 'cron'` fails type-check). * At runtime, cron IS on disk as @nestjs/schedule@6 declares it as a peer dep. */ // eslint-disable-next-line @typescript-eslint/no-require-imports const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } = // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access require('cron').CronJob as new (cronTime: string, onTick: () => void) => { start(): void }; /** * DkvSchedulerService — dynamic cron job lifecycle management for inbox polling. * * Uses `SchedulerRegistry.addCronJob()` instead of the static `@Cron()` decorator * so the polling interval can be updated at runtime when the admin changes the * module config. (Research Pattern 7: Dynamic Cron Job; Pitfall 4: ScheduleModule * must be registered in AppModule — done in Plan 01.) * * AUFTRAG JE MANDANT (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN): * * Einmal-abfragen-viele-bedienen. Beim Start laedt der Planer ueber * `DkvService.loadActiveConfigsForScheduler()` (systemgebunden ueber den * Systemkontext-Helfer, nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem * Mandanten einen EIGENEN Cron-Auftrag unter dem Registry-Namen * `dkv-inbox-poll:`. Der Tick eines Auftrags ruft * `processInbox(tenantId)` fuer GENAU diesen Mandanten — der Tick selbst * bleibt wie er ist (je Mandant gebunden, 260909-mir). * * Die Vorgaengerform hielt EIN Auftrag-Feld (`activeTenantId`) und EINEN * Registry-Namen: bei mehreren Mandanten wurde ein beliebiger bedient, die * uebrigen nie; `setInterval()` eines zweiten Mandanten ersetzte still den * Auftrag des ersten. Das Einzahl-Feld ist ERSATZLOS entfernt (Entscheidung * "promote", nicht "add-alongside": zwei Wahrheiten ueber denselben Zustand * waren genau die Form, die #21 falsch machte). * * Was mit EINEM Mandanten identisch bleibt (dkv-scheduler.service.spec.ts, * je Aussage ein Test): genau ein Auftrag, dieselbe Cron-Expression wie * bisher (`*\/15 * * * *` bzw. `0 *\/1 * * *`), der Tick ruft `processInbox` * mit dieser tenantId, eine inaktive oder fehlende Konfiguration registriert * nichts und protokolliert 'no active config found'. * * `setInterval(intervalMin, tenantId)` (tenantId PFLICHT) und * `stopJob(tenantId)` ersetzen bzw. entfernen NUR den Auftrag dieses * Mandanten. The DkvController calls `setInterval()` after saving config so * the cron job reflects any admin change immediately — without a restart. */ @Injectable() export class DkvSchedulerService implements OnModuleInit { private readonly logger = new Logger(DkvSchedulerService.name); /** Praefix der Registry-Namen; der volle Name ist `:`. */ private readonly JOB_NAME_PREFIX = 'dkv-inbox-poll'; constructor( private readonly schedulerRegistry: SchedulerRegistry, private readonly dkvService: DkvService, ) {} private jobNameFor(tenantId: string): string { return `${this.JOB_NAME_PREFIX}:${tenantId}`; } /** * On application startup: load ALL active DkvModuleConfig rows (system * context) and register one cron job per active tenant. * * Errors are caught and logged (not re-thrown) so a missing or broken * config does not prevent the rest of the application from starting. * Eine LEERE Liste fuehrt zu "nichts tun" — kein Auftrag, nichts geloescht * oder deaktiviert (Etappe-3c-Frage "Leere als Abwesenheit": nein). */ async onModuleInit(): Promise { try { const configs = await this.dkvService.loadActiveConfigsForScheduler(); if (!configs || configs.length === 0) { this.logger.log('DKV scheduler: no active config found — cron job not registered'); return; } for (const config of configs) { this.setInterval(config.pollIntervalMin, config.tenantId); } this.logger.log(`DKV scheduler initialized: ${configs.length} tenant(s)`); } catch (err) { this.logger.error( `DKV scheduler init failed: ${(err as Error).message}`, ); } } /** * Create (or replace) the inbox polling cron job of ONE tenant. * * Replaces only the job registered under this tenant's name. Called on * module init (once per active tenant) and by DkvController.saveConfig() * after the admin updates the config. * * @param intervalMin - Poll interval in minutes (e.g. 60 = every hour) * @param tenantId - Tenant to process on each tick (Pflicht) */ setInterval(intervalMin: number, tenantId: string): void { const jobName = this.jobNameFor(tenantId); // Remove existing job of THIS tenant if registered try { this.schedulerRegistry.getCronJob(jobName).stop(); this.schedulerRegistry.deleteCronJob(jobName); } catch { /* Job not yet registered — this is expected on first call */ } // Create new cron job with computed expression. // Standard cron minute field only accepts 0–59; for longer intervals use the hours field. let cronExpr: string; if (intervalMin < 60) { cronExpr = `*/${intervalMin} * * * *`; // e.g. */15 * * * * } else { const hours = Math.floor(intervalMin / 60); cronExpr = `0 */${hours} * * *`; // e.g. 0 */2 * * * } const job = new CronJobClass(cronExpr, () => { this.dkvService.processInbox(tenantId).catch((err) => this.logger.error( `DKV inbox poll failed for tenant ${tenantId}: ${(err as Error).message}`, ), ); }); // Cast required: our minimal CronJob type doesn't match cron's full type signature. // At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it. // eslint-disable-next-line @typescript-eslint/no-explicit-any this.schedulerRegistry.addCronJob(jobName, job as any); job.start(); this.logger.log( `DKV cron job registered: every ${intervalMin} minutes for tenant ${tenantId}`, ); } /** * Stop and remove the inbox polling cron job of ONE tenant. * Called by DkvController when admin sets isActive=false in config. */ stopJob(tenantId: string): void { const jobName = this.jobNameFor(tenantId); try { this.schedulerRegistry.getCronJob(jobName).stop(); this.schedulerRegistry.deleteCronJob(jobName); this.logger.log(`DKV cron job stopped and removed for tenant ${tenantId}`); } catch { /* Not registered — no-op */ } } /** * Alle Mandanten, fuer die derzeit ein Auftrag registriert ist — aus der * Registry abgeleitet (nicht aus einem eigenen Feld), fuer Tests und * Diagnose. */ registeredTenantIds(): string[] { const prefix = `${this.JOB_NAME_PREFIX}:`; const names = [...this.schedulerRegistry.getCronJobs().keys()] as string[]; return names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length)); } }