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 // URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die // Zusicherung meldet tsc, dass das lokale `job` nur die Form // `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen // CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm- // Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das // aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine // Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue // Abhaengigkeit. Beides ist hier verboten (D-03/D-04). 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 { // 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); 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); 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 { const sections: Record = {}; 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'; }