Files
tessera-ctl/apps/api/src/dkv/dkv-scheduler.service.ts
T
schalli 6e2a641d76 feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig
- 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
2026-09-14 11:46:08 +02:00

170 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}