feat(quick-260914-eym): forSystem(), is_system_context(), Systemleseregel auf fuenf Tabellen, DKV-Planer je Mandant — ein Pfad (WINDOWS #21)
- Helfer forSystem(prisma) in prisma-tenant.extension.ts (Array-Form, setzt app.system_context='true' und die beiden anderen Variablen ausdruecklich leer); forTenant()/withTenantTransaction() setzen app.system_context='' als Literal (4 neue Spec-Tests) - Migration 20260914120000_rls_system_context_read: is_system_context() (COALESCE, STABLE) und system_read_policy FOR SELECT auf DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch — lokal angewendet (36 Migrationen, pg_proc 1, 5 system_read_policy, 34 Regeln) - migration-sql.spec.ts: describe-Block fuer die neue Migration (6 Tests) - rls-scratch-check.mjs: Funktion aus der Migration geschnitten, forSystemQuery/buildInlineSystemClient, Reset in forTenantQuery/ buildInlineExtendedClient, runSystemContextChecks (4 Funktionsfaelle + 9 Kennungen DkvModuleConfig) -> Alle 216 Pruefungen bestanden - rls-access-inventory.spec.ts: fuenfte Erkennungsform const X = forSystem(, Stand system-gebunden mit Vorrangregel, FORSYSTEM_ALLOWED_CALL_SITES (exakte Zahl je Datei, 3 Tests), Proben C/D/E - DKV: loadActiveConfigsForScheduler() ueber forSystem (findMany isActive, CONFIG_SAFE_SELECT, orderBy tenantId); DkvSchedulerService mit Auftrag je Mandant dkv-inbox-poll:<tenantId>, activeTenantId ersatzlos entfernt, setInterval/stopJob je Mandant, registeredTenantIds(); Controller stopJob(tenantId); neue dkv-scheduler.service.spec.ts (7 Tests), dkv.service.spec.ts Tests 6/7 umgestellt - Klassifikation: dkv.service.ts/dkvModuleConfig system-gebunden, Header mit fuenfter Erkennungsform und viertem Stand-Wert - Baseline: 63 Dateien / 1051 Tests, tsc 0, Werkzeug 216 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -21,77 +21,70 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
|
||||
* 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 config via
|
||||
* `DkvService.loadAnyActiveConfigForScheduler()`, which pulls the first
|
||||
* active DkvModuleConfig row via findFirst() — same underlying query as
|
||||
* before, now split into its own named method (260909-mir).
|
||||
* AUFTRAG JE MANDANT (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN):
|
||||
*
|
||||
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D —
|
||||
* volle Begruendung im Kopfkommentar von
|
||||
* `DkvService.loadAnyActiveConfigForScheduler()` und im Abschnitt
|
||||
* "Bereich dkv" von docs/mandantentrennung-etappe2-fehlerrichtung.md).
|
||||
* Zwei Zustaende, beide gehoeren genannt:
|
||||
* Einmal-abfragen-viele-bedienen. Beim Start laedt der Planer ueber
|
||||
* `DkvService.loadActiveConfigsForScheduler()` (systemgebunden, `forSystem()`,
|
||||
* 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).
|
||||
*
|
||||
* - HEUTE bereits falsch, nicht nur ungenau: bei mehreren Mandanten wird
|
||||
* EIN beliebiger bedient, die uebrigen NIE — und ist ausgerechnet die
|
||||
* gezogene Zeile inaktiv, registriert der Planer gar nichts, obwohl ein
|
||||
* zweiter Mandant aktiv waere.
|
||||
* - NACH DEM SCHARFSCHALTEN (Etappe 4, WINDOWS #18) verstummt dieselbe
|
||||
* Abfrage zusaetzlich: sie liefert dann `null`, und die Protokollzeile
|
||||
* unten ("no active config found") ist auf einer frischen Installation
|
||||
* der Normalfall — sie alarmiert deshalb niemanden, obwohl ein
|
||||
* tatsaechlich eingerichteter Mandant nicht bedient wird.
|
||||
* 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).
|
||||
*
|
||||
* Fuer single-tenant deployments (heute der einzige produktive Fall) ist
|
||||
* dieselbe Abfrage stets die korrekte Config. Multi-tenant scheduling
|
||||
* (poll-once-fan-out-many, ein Cron-Auftrag je aktivem Mandanten) ist die
|
||||
* in 07-04 zurueckgestellte Mehrmandanten-Planung und bleibt eine
|
||||
* Funktionsaenderung fuer eine kuenftige Phase, kein Bindungsumbau dieses
|
||||
* Plans.
|
||||
* 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'.
|
||||
*
|
||||
* The DkvController calls `setInterval()` after saving config so the cron job
|
||||
* reflects any admin change immediately — without a service restart.
|
||||
* `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);
|
||||
|
||||
/** 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;
|
||||
/** 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 the first active DkvModuleConfig and
|
||||
* register the cron job if the module is active.
|
||||
* 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 {
|
||||
// Bewusst uebergreifender Planer-Startpfad (WINDOWS #21) — siehe
|
||||
// Kopfkommentar dieser Klasse und von
|
||||
// DkvService.loadAnyActiveConfigForScheduler().
|
||||
const config = await this.dkvService.loadAnyActiveConfigForScheduler();
|
||||
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 {
|
||||
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}`,
|
||||
@@ -100,28 +93,22 @@ export class DkvSchedulerService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or replace) the inbox polling cron job.
|
||||
* Create (or replace) the inbox polling cron job of ONE tenant.
|
||||
*
|
||||
* Replaces any existing job with the new interval. Called on module init
|
||||
* and by DkvController.saveConfig() after the admin updates the config.
|
||||
* 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
|
||||
* @param tenantId - Tenant to process on each tick (Pflicht)
|
||||
*/
|
||||
setInterval(intervalMin: number, tenantId?: string): void {
|
||||
if (tenantId) this.activeTenantId = tenantId;
|
||||
setInterval(intervalMin: number, tenantId: string): void {
|
||||
const jobName = this.jobNameFor(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
|
||||
// Remove existing job of THIS tenant if registered
|
||||
try {
|
||||
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
|
||||
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
|
||||
this.schedulerRegistry.getCronJob(jobName).stop();
|
||||
this.schedulerRegistry.deleteCronJob(jobName);
|
||||
} catch {
|
||||
/* Job not yet registered — this is expected on first call */
|
||||
}
|
||||
@@ -136,9 +123,9 @@ export class DkvSchedulerService implements OnModuleInit {
|
||||
cronExpr = `0 */${hours} * * *`; // e.g. 0 */2 * * *
|
||||
}
|
||||
const job = new CronJobClass(cronExpr, () => {
|
||||
this.dkvService.processInbox(tenant).catch((err) =>
|
||||
this.dkvService.processInbox(tenantId).catch((err) =>
|
||||
this.logger.error(
|
||||
`DKV inbox poll failed for tenant ${tenant}: ${(err as Error).message}`,
|
||||
`DKV inbox poll failed for tenant ${tenantId}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -146,25 +133,37 @@ export class DkvSchedulerService implements OnModuleInit {
|
||||
// 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);
|
||||
this.schedulerRegistry.addCronJob(jobName, job as any);
|
||||
job.start();
|
||||
|
||||
this.logger.log(
|
||||
`DKV cron job registered: every ${intervalMin} minutes for tenant ${tenant}`,
|
||||
`DKV cron job registered: every ${intervalMin} minutes for tenant ${tenantId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and remove the inbox polling cron job.
|
||||
* Stop and remove the inbox polling cron job of ONE tenant.
|
||||
* Called by DkvController when admin sets isActive=false in config.
|
||||
*/
|
||||
stopJob(): void {
|
||||
stopJob(tenantId: string): void {
|
||||
const jobName = this.jobNameFor(tenantId);
|
||||
try {
|
||||
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
|
||||
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
|
||||
this.logger.log('DKV cron job stopped and removed');
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user