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:
@@ -0,0 +1,181 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DkvSchedulerService } from './dkv-scheduler.service';
|
||||
|
||||
/**
|
||||
* DkvSchedulerService.spec (Etappe 3c, 260914-eym, WINDOWS #21) — der
|
||||
* Planer fuehrt seit diesem Durchlauf EINEN Cron-Auftrag JE aktivem
|
||||
* Mandanten (`dkv-inbox-poll:<tenantId>`). Diese Tests nageln fest:
|
||||
*
|
||||
* - IDENTITAET MIT EINEM MANDANTEN (morgen alpha, ein Mandant): genau ein
|
||||
* Auftrag, dieselbe Cron-Expression wie bisher, der Tick ruft
|
||||
* `processInbox` mit dieser tenantId, eine inaktive/fehlende Config
|
||||
* registriert nichts und protokolliert dieselbe Zeile wie bisher.
|
||||
* - INVARIANTE MIT ZWEI MANDANTEN (assumption-delta "promote"): zwei
|
||||
* Auftraege; die Aenderung des einen laesst den anderen unberuehrt.
|
||||
* - FEHLERTOLERANZ: ein werfender Startpfad blockiert den Start nicht.
|
||||
*
|
||||
* Fake-Registry (Map-basiert, `getCronJob` wirft bei Unbekannt wie
|
||||
* @nestjs/schedule), Fake-DkvService, ECHTES `cron` (liegt unter
|
||||
* apps/api/node_modules als Peer von @nestjs/schedule) — `cronTime.source`
|
||||
* und `fireOnTick()` sind die beobachtbaren Eigenschaften eines Auftrags.
|
||||
*/
|
||||
|
||||
function makeFakeRegistry() {
|
||||
const jobs = new Map<string, any>();
|
||||
return {
|
||||
__jobs: jobs,
|
||||
addCronJob: vi.fn((name: string, job: any) => {
|
||||
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
|
||||
jobs.set(name, job);
|
||||
}),
|
||||
getCronJob: vi.fn((name: string) => {
|
||||
const job = jobs.get(name);
|
||||
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
|
||||
return job;
|
||||
}),
|
||||
deleteCronJob: vi.fn((name: string) => {
|
||||
const job = jobs.get(name);
|
||||
if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`);
|
||||
jobs.delete(name);
|
||||
}),
|
||||
getCronJobs: vi.fn(() => jobs),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeDkvService(configs: Array<{ tenantId: string; pollIntervalMin: number; isActive: boolean }> | Error) {
|
||||
return {
|
||||
loadActiveConfigsForScheduler: vi.fn(async () => {
|
||||
if (configs instanceof Error) throw configs;
|
||||
return configs.filter((c) => c.isActive);
|
||||
}),
|
||||
processInbox: vi.fn(async (_tenantId: string) => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makeScheduler(
|
||||
configs: Array<{ tenantId: string; pollIntervalMin: number; isActive: boolean }> | Error,
|
||||
) {
|
||||
const registry = makeFakeRegistry();
|
||||
const dkvService = makeFakeDkvService(configs);
|
||||
const scheduler = new DkvSchedulerService(registry as any, dkvService as any);
|
||||
const logSpy = vi.spyOn((scheduler as any).logger, 'log').mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn((scheduler as any).logger, 'error').mockImplementation(() => undefined);
|
||||
return { registry, dkvService, scheduler, logSpy, errorSpy };
|
||||
}
|
||||
|
||||
describe('DkvSchedulerService — ein Auftrag je Mandant (260914-eym, WINDOWS #21)', () => {
|
||||
const registries: ReturnType<typeof makeFakeRegistry>[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
// Jeden registrierten (echten) Cron-Auftrag stoppen, sonst haelt ein
|
||||
// laufender Timer den Testprozess offen.
|
||||
for (const registry of registries) {
|
||||
for (const job of registry.__jobs.values()) job.stop();
|
||||
registry.__jobs.clear();
|
||||
}
|
||||
registries.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Test 1: EIN aktiver Mandant, pollIntervalMin 15 -> genau ein Auftrag dkv-inbox-poll:<t> mit cronTime.source "*/15 * * * *" (Identitaet zu heute)', async () => {
|
||||
const { registry, scheduler, dkvService } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: true }]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
expect(dkvService.loadActiveConfigsForScheduler).toHaveBeenCalledTimes(1);
|
||||
expect([...registry.__jobs.keys()]).toEqual(['dkv-inbox-poll:t1']);
|
||||
expect(scheduler.registeredTenantIds()).toEqual(['t1']);
|
||||
const job = registry.__jobs.get('dkv-inbox-poll:t1');
|
||||
expect(job.cronTime.source).toBe('*/15 * * * *');
|
||||
expect(job.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 2: pollIntervalMin 120 -> "0 */2 * * *" (Stundenfeld, unveraenderte Berechnung)', async () => {
|
||||
const { registry, scheduler } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 120, isActive: true }]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
|
||||
expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('0 */2 * * *');
|
||||
});
|
||||
|
||||
it('Test 3: fireOnTick() ruft processInbox genau mit dieser tenantId', async () => {
|
||||
const { registry, scheduler, dkvService } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: true }]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
registry.__jobs.get('dkv-inbox-poll:t1').fireOnTick();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(dkvService.processInbox).toHaveBeenCalledTimes(1);
|
||||
expect(dkvService.processInbox).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
|
||||
it('Test 4: inaktive oder keine Config -> kein Auftrag, Protokollzeile "no active config found"', async () => {
|
||||
const inactive = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: false }]);
|
||||
registries.push(inactive.registry);
|
||||
await inactive.scheduler.onModuleInit();
|
||||
expect(inactive.registry.__jobs.size).toBe(0);
|
||||
expect(inactive.scheduler.registeredTenantIds()).toEqual([]);
|
||||
expect(inactive.logSpy).toHaveBeenCalledWith(
|
||||
'DKV scheduler: no active config found — cron job not registered',
|
||||
);
|
||||
|
||||
const none = makeScheduler([]);
|
||||
registries.push(none.registry);
|
||||
await none.scheduler.onModuleInit();
|
||||
expect(none.registry.__jobs.size).toBe(0);
|
||||
expect(none.logSpy).toHaveBeenCalledWith(
|
||||
'DKV scheduler: no active config found — cron job not registered',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 5 (Invariante): ZWEI Mandanten -> zwei Auftraege; setInterval(30, t2) ersetzt nur t2, stopJob(t1) entfernt nur t1', async () => {
|
||||
const { registry, scheduler, dkvService } = makeScheduler([
|
||||
{ tenantId: 't1', pollIntervalMin: 15, isActive: true },
|
||||
{ tenantId: 't2', pollIntervalMin: 60, isActive: true },
|
||||
]);
|
||||
registries.push(registry);
|
||||
|
||||
await scheduler.onModuleInit();
|
||||
expect(scheduler.registeredTenantIds().sort()).toEqual(['t1', 't2']);
|
||||
expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('*/15 * * * *');
|
||||
expect(registry.__jobs.get('dkv-inbox-poll:t2').cronTime.source).toBe('0 */1 * * *');
|
||||
const t1JobBefore = registry.__jobs.get('dkv-inbox-poll:t1');
|
||||
|
||||
scheduler.setInterval(30, 't2');
|
||||
expect(registry.__jobs.get('dkv-inbox-poll:t2').cronTime.source).toBe('*/30 * * * *');
|
||||
expect(registry.__jobs.get('dkv-inbox-poll:t1')).toBe(t1JobBefore);
|
||||
expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('*/15 * * * *');
|
||||
|
||||
// Der Tick von t2 ruft weiterhin nur t2.
|
||||
registry.__jobs.get('dkv-inbox-poll:t2').fireOnTick();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(dkvService.processInbox).toHaveBeenCalledWith('t2');
|
||||
expect(dkvService.processInbox).not.toHaveBeenCalledWith('t1');
|
||||
|
||||
scheduler.stopJob('t1');
|
||||
expect(scheduler.registeredTenantIds()).toEqual(['t2']);
|
||||
expect(t1JobBefore.isActive).toBe(false);
|
||||
expect(registry.__jobs.get('dkv-inbox-poll:t2').isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 6: loadActiveConfigsForScheduler wirft -> Fehler gefangen und protokolliert, kein Auftrag, Start nicht blockiert', async () => {
|
||||
const { registry, scheduler, errorSpy } = makeScheduler(new Error('db down'));
|
||||
registries.push(registry);
|
||||
|
||||
await expect(scheduler.onModuleInit()).resolves.toBeUndefined();
|
||||
|
||||
expect(registry.__jobs.size).toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith('DKV scheduler init failed: db down');
|
||||
});
|
||||
|
||||
it('Test 7: stopJob fuer einen nicht registrierten Mandanten ist ein No-Op (kein Throw)', () => {
|
||||
const { registry, scheduler } = makeScheduler([]);
|
||||
registries.push(registry);
|
||||
|
||||
expect(() => scheduler.stopJob('unbekannt')).not.toThrow();
|
||||
expect(registry.__jobs.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class DkvController {
|
||||
if (dto.isActive && dto.pollIntervalMin) {
|
||||
this.dkvScheduler.setInterval(dto.pollIntervalMin, tenantId);
|
||||
} else if (dto.isActive === false) {
|
||||
this.dkvScheduler.stopJob();
|
||||
this.dkvScheduler.stopJob(tenantId);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -22,6 +22,8 @@ import { DkvService } from './dkv.service';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
// Systemkontext (260914-eym): der Planer-Startpfad liest ueber forSystem().
|
||||
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
// `import * as fs from 'fs'` under ESM has a non-configurable module
|
||||
@@ -45,6 +47,7 @@ function _applySelect(row: any, select: Record<string, boolean> | undefined) {
|
||||
function makeFakePrisma() {
|
||||
const configs = new Map<string, any>(); // key: tenantId
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
const systemCallLog: { model: string; method: string }[] = [];
|
||||
|
||||
const dkvModuleConfig = {
|
||||
findFirst: vi.fn(async ({ select }: { select?: Record<string, boolean> } = {}) => {
|
||||
@@ -215,6 +218,7 @@ function makeFakePrisma() {
|
||||
dkvVehicleMaster,
|
||||
dkvInvoiceHistory,
|
||||
__boundCallLog: boundCallLog,
|
||||
__systemCallLog: systemCallLog,
|
||||
__seedConfig(tenantId: string, row: Record<string, unknown>) {
|
||||
configs.set(tenantId, { tenantId, ...row });
|
||||
},
|
||||
@@ -231,6 +235,40 @@ function makeFakePrisma() {
|
||||
...row,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog,
|
||||
* NICHT in __boundCallLog. dkvModuleConfig.findMany filtert ueber die
|
||||
* Map nach where.isActive und liefert nach tenantId sortiert.
|
||||
*/
|
||||
__makeSystemClient() {
|
||||
return {
|
||||
dkvModuleConfig: {
|
||||
findMany: async ({
|
||||
where,
|
||||
select,
|
||||
orderBy,
|
||||
}: {
|
||||
where?: { isActive?: boolean };
|
||||
select?: Record<string, boolean>;
|
||||
orderBy?: { tenantId?: 'asc' | 'desc' };
|
||||
} = {}) => {
|
||||
systemCallLog.push({ model: 'dkvModuleConfig', method: 'findMany' });
|
||||
let rows = Array.from(configs.values());
|
||||
if (where && typeof where.isActive === 'boolean') {
|
||||
rows = rows.filter((r) => r.isActive === where.isActive);
|
||||
}
|
||||
if (orderBy?.tenantId) {
|
||||
rows = rows.sort((a, b) =>
|
||||
orderBy.tenantId === 'asc'
|
||||
? a.tenantId.localeCompare(b.tenantId)
|
||||
: b.tenantId.localeCompare(a.tenantId),
|
||||
);
|
||||
}
|
||||
return rows.map((r) => _applySelect(r, select));
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
__makeBoundClient(tenantId: string) {
|
||||
const wrapModel = (model: Record<string, any>, modelName: string, methods: string[]) => {
|
||||
const wrapped: any = {};
|
||||
@@ -409,33 +447,45 @@ describe('DkvService — Bindung an forTenant() (260909-mir)', () => {
|
||||
expect(result.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('Test 6: der bewusst uebergreifende Planer-Startpfad steht NICHT im Bindungsprotokoll — Fehlen der Bindung ist hier die bestandene Erwartung, NICHT spaeter "reparieren"', async () => {
|
||||
it('Test 6 (umgedreht, 260914-eym): der Planer-Startpfad erzeugt GENAU EINEN System-Aufruf (dkvModuleConfig.findMany) und KEINEN gebundenen — WINDOWS #21 geschlossen', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: 'enc(egal)' });
|
||||
const { service } = makeDkvService(prisma);
|
||||
|
||||
await service.loadAnyActiveConfigForScheduler();
|
||||
await service.loadActiveConfigsForScheduler();
|
||||
|
||||
expect(prisma.__systemCallLog).toEqual([{ model: 'dkvModuleConfig', method: 'findMany' }]);
|
||||
expect(
|
||||
prisma.__boundCallLog.length,
|
||||
`der Planer-Startpfad darf KEINEN gebundenen Aufruf erzeugen, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit (Befund D — Entlastung wird festgeschrieben, nicht geglaubt)', async () => {
|
||||
it('Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit und genau die aktiven Mandanten, nach tenantId sortiert (260914-eym)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
prisma.__seedConfig('t2', {
|
||||
id: 'cfg-2',
|
||||
protocol: 'imap',
|
||||
isActive: true,
|
||||
pollIntervalMin: 30,
|
||||
encryptedInboxCreds: 'enc(sollte-nie-hier-auftauchen)',
|
||||
});
|
||||
prisma.__seedConfig('t3', { id: 'cfg-3', protocol: 'imap', isActive: false, pollIntervalMin: 60 });
|
||||
prisma.__seedConfig('t1', {
|
||||
id: 'cfg-1',
|
||||
protocol: 'imap',
|
||||
isActive: true,
|
||||
pollIntervalMin: 15,
|
||||
encryptedInboxCreds: 'enc(sollte-nie-hier-auftauchen)',
|
||||
});
|
||||
const { service } = makeDkvService(prisma);
|
||||
|
||||
const result = await service.loadAnyActiveConfigForScheduler();
|
||||
const result = await service.loadActiveConfigsForScheduler();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect((result as any).encryptedInboxCreds).toBeUndefined();
|
||||
expect(result.map((r: any) => r.tenantId)).toEqual(['t1', 't2']);
|
||||
for (const row of result) {
|
||||
expect((row as any).encryptedInboxCreds).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Aufgabe 3 (260909-mir): dkvVehicleMaster / dkvInvoiceHistory / getExportFile ───
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DkvExportService } from './dkv-export.service';
|
||||
import { DkvMailService } from './dkv-mail.service';
|
||||
import { DkvParserService } from './dkv-parser.service';
|
||||
@@ -56,13 +56,17 @@ const CONFIG_SAFE_SELECT = {
|
||||
* - T-07-09: Export filename validated against safe pattern before reading (traversal guard)
|
||||
* - Single-flight guard: prevents concurrent inbox processing (Pitfall 7)
|
||||
*
|
||||
* Multi-tenant note (v1): The scheduler loads its startup config via
|
||||
* loadAnyActiveConfigForScheduler(), which stays bewusst UNGEBUNDEN
|
||||
* (WINDOWS #21, see that method's own doc comment). Each processInbox(tenantId)
|
||||
* call is per-tenant and fully forTenant()-bound (260909-mir). The Controller
|
||||
* scopes all operations to req.tenantId. Full per-tenant scheduling (one cron
|
||||
* per active tenant) is deferred to a future plan — v1 covers single-tenant
|
||||
* deployments.
|
||||
* Multi-tenant note (seit 260914-eym, Etappe 3c): Der Planer laedt seinen
|
||||
* Startpfad ueber `loadActiveConfigsForScheduler()` — SYSTEMGEBUNDEN
|
||||
* (`forSystem()`, liest ALLE aktiven Konfigurationen ueber alle Mandanten,
|
||||
* nur lesend) — und registriert je aktivem Mandanten einen eigenen
|
||||
* Cron-Auftrag (einmal-abfragen-viele-bedienen, WINDOWS #21 geschlossen).
|
||||
* Each processInbox(tenantId) call is per-tenant and fully forTenant()-bound
|
||||
* (260909-mir). The Controller scopes all operations to req.tenantId.
|
||||
*
|
||||
* Bewusst NICHT angefasst (260914-eym): der Single-Flight-Riegel
|
||||
* `processing` ist EIN prozessweites Boolean, nicht je Mandant — siehe
|
||||
* Kommentar am Feld und WINDOWS-Eintrag (Ledger).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DkvService {
|
||||
@@ -72,6 +76,14 @@ export class DkvService {
|
||||
* Single-flight guard: if processing is already in progress, any concurrent
|
||||
* call to processInbox() returns early without starting a second pipeline
|
||||
* run (Pitfall 7 — prevents the prune race condition and duplicate records).
|
||||
*
|
||||
* PROZESSWEIT, nicht je Mandant (260914-eym, bewusst unangetastet): seit
|
||||
* je aktivem Mandanten ein eigener Cron-Auftrag laeuft, koennen sich zwei
|
||||
* Ticks verschiedener Mandanten ueberschneiden — der zweite bricht dann
|
||||
* still ab und wartet bis zum naechsten Intervall (Verzoegerung, kein
|
||||
* Datenverlust; mit EINEM Mandanten unveraendert). Loesungsweg: Riegel je
|
||||
* Mandant (Set<tenantId>) — als Ledger-Eintrag in .planning/WINDOWS.md
|
||||
* gefuehrt, nicht in diesem Durchlauf gebaut (Auftrag: Tick unangetastet).
|
||||
*/
|
||||
private processing = false;
|
||||
|
||||
@@ -102,7 +114,8 @@ export class DkvService {
|
||||
* optionalen Parameter, hinter dem der eine Zweig gebunden werden MUSSTE
|
||||
* und der andere gebunden werden DURFTE NICHT — genau die Form, die
|
||||
* dieser Umbau aufloest. Der uebergreifende Zweig ist jetzt eine eigene,
|
||||
* benannte Methode: `loadAnyActiveConfigForScheduler()` unten.
|
||||
* benannte Methode: `loadActiveConfigsForScheduler()` unten (seit
|
||||
* 260914-eym systemgebunden, eine Zeile je aktivem Mandanten).
|
||||
*/
|
||||
async loadConfig(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
@@ -113,40 +126,39 @@ export class DkvService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the DKV module config for a single, ARBITRARY tenant that has one
|
||||
* configured — used EXCLUSIVELY by DkvSchedulerService.onModuleInit() to
|
||||
* seed the one (v1, single-tenant) cron job at boot time.
|
||||
* Alle AKTIVEN DKV-Konfigurationen ueber ALLE Mandanten — verwendet
|
||||
* AUSSCHLIESSLICH von DkvSchedulerService.onModuleInit(), das je Zeile
|
||||
* einen eigenen Cron-Auftrag `dkv-inbox-poll:<tenantId>` registriert.
|
||||
*
|
||||
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D
|
||||
* — siehe .planning/WINDOWS.md und den Abschnitt "Bereich dkv" in
|
||||
* docs/mandantentrennung-etappe2-fehlerrichtung.md fuer die vollstaendige
|
||||
* Begruendung, hier nur die Kurzfassung):
|
||||
* SYSTEMGEBUNDEN (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN): liest
|
||||
* ueber `forSystem()` (Sitzungsvariable `app.system_context = 'true'`,
|
||||
* Regel `system_read_policy ... FOR SELECT` auf "DkvModuleConfig",
|
||||
* Migration 20260914120000). Warum VIELE statt EINER:
|
||||
*
|
||||
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
|
||||
* Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient
|
||||
* die uebrigen NIE. 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` statt einer beliebigen
|
||||
* Zeile, der Planer protokolliert das als Normalfall und richtet fuer
|
||||
* JEDEN Mandanten nichts ein — ohne Fehler, ohne Alarm.
|
||||
* - Binden wuerde diesen Pfad garantiert leer laufen lassen (es gibt beim
|
||||
* Boot strukturell keinen Mandantenkontext). Umbau auf
|
||||
* einmal-abfragen-viele-bedienen ist die in 07-04 zurueckgestellte
|
||||
* Mehrmandanten-Planung — eine Funktionsaenderung, kein Bindungsumbau,
|
||||
* und deshalb hier NICHT vorgenommen.
|
||||
* - Praezedenzfall: `LdapConfigService.getAllActiveConfigs()`
|
||||
* (260909-ipc, Befund B) — mit der einen Unsymmetrie, die dieser
|
||||
* Praezedenzfall NICHT deckt: `getAllActiveConfigs` ist heute korrekt
|
||||
* und verstummt erst spaeter, dieser Pfad ist HEUTE bereits falsch UND
|
||||
* verstummt zusaetzlich spaeter.
|
||||
* - Die Vorgaengerform `findFirst()` ohne Bedingung zog bei mehreren
|
||||
* Mandanten EINEN beliebigen und bediente die uebrigen NIE — HEUTE
|
||||
* schon falsch (260909-mir, Befund D). `findMany({ where: { isActive:
|
||||
* true } })` liefert jeden aktiven Mandanten genau einmal, sortiert nach
|
||||
* tenantId (deterministische Reihenfolge der Auftraege).
|
||||
* - Das VERSTUMMEN nach dem Scharfschalten (Etappe 4) ist strukturell
|
||||
* ausgeschlossen: ohne Systemkontext saehe dieser Pfad unter einer Rolle
|
||||
* ohne BYPASSRLS NULL Zeilen; `system_read_policy` oeffnet genau diese
|
||||
* Tabelle fuer genau diesen Kontext, nur lesend (Werkzeugbeleg
|
||||
* `dkvmoduleconfig-systemkontext-sieht-beide-mandanten`).
|
||||
* - Mit EINEM Mandanten ist das Ergebnis beobachtbar identisch zur
|
||||
* Vorgaengerform: eine Zeile, derselbe Auftrag, dieselbe Cron-Expression
|
||||
* (dkv-scheduler.service.spec.ts, Test 1).
|
||||
*
|
||||
* Das Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4
|
||||
* (`apps/api/scripts/rls-preflight.mjs`), NICHT in diesen Durchlauf.
|
||||
* `CONFIG_SAFE_SELECT`: die verschluesselten Zugangsdaten bleiben draussen
|
||||
* (T-07-12) — der Planer braucht nur tenantId und pollIntervalMin.
|
||||
*/
|
||||
async loadAnyActiveConfigForScheduler() {
|
||||
return this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT });
|
||||
async loadActiveConfigsForScheduler() {
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
return systemPrisma.dkvModuleConfig.findMany({
|
||||
where: { isActive: true },
|
||||
select: CONFIG_SAFE_SELECT,
|
||||
orderBy: { tenantId: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user