6e2a641d76
- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos entfernt; MailService baut je Versand einen nodemailer-Transport aus getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts (4 Tests, T-GWH-03 geschlossen) - settings: Startpfad-Methode samt vier Spec-Tests geloescht; auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung) - ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId); Tests 301/306 umgedreht, neuer Altzeilen-Test - tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test) - tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test) - tender-notifications.integration.spec: Mock um forSystem - Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8), TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung ldapconfig-systemkontext-include-fieldmappings-beider-mandanten -> Alle 253 Pruefungen bestanden - Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe; Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert) - Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden - Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen: (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt, cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245 rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei admin-seed -> 3 rot - Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
170 lines
7.1 KiB
TypeScript
170 lines
7.1 KiB
TypeScript
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:<tenantId>`. 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 `<Praefix>:<tenantId>`. */
|
||
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<void> {
|
||
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));
|
||
}
|
||
}
|