feat(07-04): DkvSchedulerService + DkvController — dynamic cron + REST surface
- DkvSchedulerService: SchedulerRegistry.addCronJob (dynamic interval, not static @Cron)
- onModuleInit loads first active config (v1 single-tenant, documented in SUMMARY)
- setInterval() replaces existing job and registers new one with */ cron expression
- stopJob() removes job when config.isActive=false
- cron package resolved via require() workaround (pnpm strict isolation: transitive dep)
- DkvController: 12 handlers all carrying @Roles(Role.ADMIN, Role.SUPER_ADMIN)
- Routes: GET/PUT config, POST check-now, POST test-connection, GET history,
GET exports/:filename, GET/POST/PUT/DELETE vehicles, POST vehicles/import
- vehicles/import uses FileInterceptor('file') for CSV multipart upload
- exports/:filename streams file as attachment; traversal guard in DkvService
- Controller coordinates scheduler after PUT /dkv/config (no circular dep)
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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.)
|
||||
*
|
||||
* Multi-tenant note (v1): On init, the scheduler loads the first active
|
||||
* DkvModuleConfig row via findFirst(). For single-tenant deployments this
|
||||
* is always the correct config. Multi-tenant scheduling (one cron job per
|
||||
* active tenant) is deferred to a future plan.
|
||||
*
|
||||
* The DkvController calls `setInterval()` after saving config so the cron job
|
||||
* reflects any admin change immediately — without a service restart.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DkvSchedulerService implements OnModuleInit {
|
||||
private readonly logger = new Logger(DkvSchedulerService.name);
|
||||
|
||||
/** Name of the managed cron job in the SchedulerRegistry. */
|
||||
private readonly JOB_NAME = 'dkv-inbox-poll';
|
||||
|
||||
/**
|
||||
* The tenantId this scheduler is currently serving.
|
||||
* Updated when setInterval() is called with a new tenantId.
|
||||
*/
|
||||
private activeTenantId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
private readonly dkvService: DkvService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* On application startup: load the first active DkvModuleConfig and
|
||||
* register the cron job if the module is active.
|
||||
*
|
||||
* Errors are caught and logged (not re-thrown) so a missing or broken
|
||||
* config does not prevent the rest of the application from starting.
|
||||
*/
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
// loadConfig without tenantId → findFirst (v1 single-tenant)
|
||||
const config = await this.dkvService.loadConfig();
|
||||
if (config?.isActive && config.tenantId) {
|
||||
this.activeTenantId = config.tenantId;
|
||||
this.setInterval(config.pollIntervalMin, config.tenantId);
|
||||
this.logger.log(
|
||||
`DKV scheduler initialized: every ${config.pollIntervalMin} min for tenant ${config.tenantId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log('DKV scheduler: no active config found — cron job not registered');
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`DKV scheduler init failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or replace) the inbox polling cron job.
|
||||
*
|
||||
* Replaces any existing job with the new interval. Called on module init
|
||||
* 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
|
||||
*/
|
||||
setInterval(intervalMin: number, tenantId?: string): void {
|
||||
if (tenantId) this.activeTenantId = tenantId;
|
||||
|
||||
if (!this.activeTenantId) {
|
||||
this.logger.warn('DKV scheduler: no active tenantId — cron job not created');
|
||||
return;
|
||||
}
|
||||
|
||||
const tenant = this.activeTenantId;
|
||||
|
||||
// Remove existing job if registered
|
||||
try {
|
||||
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
|
||||
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
|
||||
} catch {
|
||||
/* Job not yet registered — this is expected on first call */
|
||||
}
|
||||
|
||||
// Create new cron job with computed expression: every N minutes
|
||||
const cronExpr = `*/${intervalMin} * * * *`;
|
||||
const job = new CronJobClass(cronExpr, () => {
|
||||
this.dkvService.processInbox(tenant).catch((err) =>
|
||||
this.logger.error(
|
||||
`DKV inbox poll failed for tenant ${tenant}: ${(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(this.JOB_NAME, job as any);
|
||||
job.start();
|
||||
|
||||
this.logger.log(
|
||||
`DKV cron job registered: every ${intervalMin} minutes for tenant ${tenant}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and remove the inbox polling cron job.
|
||||
* Called by DkvController when admin sets isActive=false in config.
|
||||
*/
|
||||
stopJob(): void {
|
||||
try {
|
||||
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
|
||||
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
|
||||
this.logger.log('DKV cron job stopped and removed');
|
||||
} catch {
|
||||
/* Not registered — no-op */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user