Files
tessera-ctl/apps/api/src/tenders/tender-digest.scheduler.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

221 lines
10 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailItem, TenderMailService } from './tender-mail.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). Reuses the exact `TenderSchedulerService`/`DkvSchedulerService`
* resolution workaround verbatim.
*/
// 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 };
/**
* TenderDigestScheduler — a SINGLE global cron job driving the tender-radar
* digest send (NOTIFY-01). Deliberately mirrors `TenderSchedulerService`'s
* poll-once-fan-out-many pattern, NOT `DkvSchedulerService`'s documented
* "v1 single-tenant, find-first-row" pattern (RESEARCH.md Pitfall 1): there is
* no per-tenant cron job and no per-tenant "active tenant" instance field.
* One job, registered once, iterates every due user of every tenant via
* `findMany` on every tick.
*
* Runs daily at 07:00 (server-local cron tick; due-date evaluation itself
* is Europe/Berlin-aware for the weekly weekday check). Selects candidate
* users as the distinct `userId`s that have at least one un-notified
* `TenderMatch`, resolves each user's `TenderNotificationPref.digestInterval`
* (missing row -> 'daily' default, D-01), and — for due users only — groups
* their un-notified matches by saved-search profile name (D-02) into ONE
* `TenderMailService.sendDigest` call. `TenderMatch.notifiedAt`/`notifiedChannel`
* are stamped ONLY after a successful (non-skipped) send — the single
* `notifiedAt IS NULL` eligibility gate that structurally prevents
* double-sends across digest and instant channels (D-06).
*
* Robustness (Pitfall 6): each candidate user is processed inside its own
* try/catch. A missing SMTP config, a send failure, or an unexpected thrown
* error for one user/tenant leaves that user's matches `notifiedAt=NULL`
* (retried on the next run) and never aborts the run for the remaining
* users — a broken tenant must never take down every other tenant's digest.
*/
@Injectable()
export class TenderDigestScheduler implements OnModuleInit {
private readonly logger = new Logger(TenderDigestScheduler.name);
/** Name of the single, platform-global managed cron job. */
private readonly JOB_NAME = 'tender-digest';
/** Daily at 07:00 — a single platform-wide tick, no tenant dimension. */
private readonly CRON_EXPR = '0 7 * * *';
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly prisma: PrismaService,
private readonly mail: TenderMailService,
) {}
/**
* Registers the single global digest cron job on application startup.
* Errors are caught and logged (never re-thrown) so a scheduling issue
* never prevents the rest of the application from starting.
*/
onModuleInit(): void {
try {
// Remove existing job if already registered (e.g. hot-reload/tests).
try {
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
} catch {
/* Job not yet registered — expected on first boot */
}
const job = new CronJobClass(this.CRON_EXPR, () => {
this.runDigest().catch((err) =>
this.logger.error(`Tender digest run failed: ${(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(
`Tender digest scheduler registered: ${this.CRON_EXPR} (single global job — all tenants/users, no per-tenant dimension)`,
);
} catch (err) {
this.logger.error(`Tender digest scheduler init failed: ${(err as Error).message}`);
}
}
/**
* Core digest run — the single global tick's fan-out over every due user
* of every tenant. NEVER a per-tenant job, NEVER a first-row-only lookup (Pitfall 1).
*
* @param now - injectable clock for the weekly-weekday check (defaults to
* the real current time); tests pass a fixed date instead of faking the
* system clock.
*/
async runDigest(now: Date = new Date()): Promise<void> {
// Candidate users: distinct userId with at least one un-notified match,
// across ALL tenants — a single findMany, never a per-tenant iteration.
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): `forSystem()` liest TenderMatch
// ALLER Mandanten NUR lesend (`system_read_policy ... FOR SELECT`,
// Migration 20260914120000) — ohne diese Regel saehe der Digest nach dem
// Scharfschalten 0 Kandidaten und wuerde stumm. Die Schleife unten
// bleibt je Kandidatenzeile GEBUNDEN (bewusst ohne Benutzer, wie in 3b).
// Eine LEERE Kandidatenliste ist Nichtstun: `notifiedAt` bleibt NULL.
//
// Zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
// ausgewaehlt (nicht Teil von `distinct`), damit die Schleife unten
// ueberhaupt an einen Mandanten binden KANN. Sonderfall, NICHT geloest:
// ein Nutzer koennte Treffer unter zwei verschiedenen Mandanten haben
// (der denormalisierte Wert kann bei einem Mandantenwechsel veralten) —
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
const systemPrisma = forSystem(this.prisma) as any;
const candidates: { userId: string; tenantId: string }[] = await systemPrisma.tenderMatch.findMany({
where: { notifiedAt: null },
select: { userId: true, tenantId: true },
distinct: ['userId'],
});
if (!candidates.length) return;
const weeklyDue = isMondayInBerlin(now);
for (const { userId, tenantId } of candidates) {
try {
// Je-Treffer-Haelfte, gebunden an den Mandanten DIESER
// Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener
// Client fuer alle Zugriffe dieses Schleifendurchlaufs.
//
// Bewusst OHNE Benutzer (260911-nke, Etappe 3b): dieser Scheduler ist
// ein Hintergrunddienst, kein Nutzer-CRUD-Aufrufer — er liest UND
// schreibt fuer den Nutzer, nicht ALS ihn eingeloggt. Die `IS NULL
// OR`-Form der Regeln macht das zur bewussten Eigenschaft: ohne
// `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor
// der Migration. Ein Systemkontext fuer Hintergrunddienste ist
// Etappe 3c, nicht Teil dieser Aenderung.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const pref = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId },
});
// Missing pref row -> daily default (D-01).
const interval = pref?.digestInterval ?? 'daily';
if (interval === 'off') continue;
if (interval === 'weekly' && !weeklyDue) continue;
// 'daily' (or any unrecognized value) -> due every run.
const matches = await tenantPrisma.tenderMatch.findMany({
where: { userId, notifiedAt: null },
include: { tender: true, savedSearch: true },
orderBy: { savedSearch: { name: 'asc' } },
});
if (!matches.length) continue;
const user = await tenantPrisma.user.findUnique({ where: { id: userId } });
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
// Mailversand wird uebersprungen (zugesagtes Verhalten).
if (!user?.email) continue;
const sections = groupMatchesByProfile(matches);
const sent = await this.mail.sendDigest({ email: user.email }, user.tenantId, sections);
if (sent) {
await tenantPrisma.tenderMatch.updateMany({
where: { id: { in: matches.map((m: { id: string }) => m.id) } },
data: { notifiedAt: new Date(), notifiedChannel: 'digest' },
});
}
// sent === false (no SMTP config or send failure) -> notifiedAt
// stays NULL, this user's matches are retried on the next run.
} catch (err) {
// One broken user/tenant (DB error, malformed pref, etc.) must
// never abort the run for the remaining users (Pitfall 6).
this.logger.error(
`Tender digest failed for user ${userId}: ${(err as Error).message}`,
);
}
}
}
}
/**
* Groups a flat list of matches (each including its `savedSearch` and
* `tender` relations) into the `{ profileName: tender[] }` shape
* `TenderMailService.sendDigest` expects (D-02 — one mail, sectioned by
* saved-search profile).
*/
function groupMatchesByProfile(
matches: Array<{ savedSearch: { name: string }; tender: TenderMailItem }>,
): Record<string, TenderMailItem[]> {
const sections: Record<string, TenderMailItem[]> = {};
for (const match of matches) {
const profileName = match.savedSearch.name;
if (!sections[profileName]) sections[profileName] = [];
sections[profileName].push(match.tender);
}
return sections;
}
/** True when `now` falls on a Monday in the Europe/Berlin timezone. */
function isMondayInBerlin(now: Date): boolean {
const weekday = now.toLocaleDateString('en-US', {
timeZone: 'Europe/Berlin',
weekday: 'short',
});
return weekday === 'Mon';
}