d8fb9ae07d
httpntlm (exchange.provider, exchange-inbox.provider): NtlmOptions und
NtlmResponse beschreiben genau das, was uebergeben und gelesen wird. Die
ueberfluessige Zusicherung (httpntlm as any) faellt weg.
Graph-Rueckrufe (exchange.provider :157/:313): AuthProviderCallback aus dem
SDK selbst statt Handannotation - als import type, also ohne den dynamischen
Import zur Laufzeit zurueckzunehmen.
imapflow: streamToBuffer() nimmt Readable statt NodeJS.ReadableStream (alle
drei Aufrufer reichen client.download().content herein, imapflow deklariert
das als Readable) - damit traegt der Typ destroy() und die Zusicherung
faellt. node.parameters?.name war ebenfalls schon getypt.
nodemailer: ResolvedTransport.options wird SMTPTransport.Options; beide
Zweige bauen reine SMTP-Optionen, createTransport() nimmt sie ohne
Zusicherung.
node-forge: die vier let p7: any werden Captured<PkcsEnvelopedData |
PkcsSignedData> - der MITGELIEFERTE Typ. Die Lesestellen grenzen mit
'certificates' in p7 ein statt zuzusichern; verhaltensgleich, weil der
enveloped-Form das Feld fehlt und beide Schreibweisen dann die leere Liste
liefern. cert.siginfo war bereits getypt.
apps/web/src/test/setup.ts: expect.extend(matchers) traegt ohne Zusicherung
- geprueft im echten Typlauf (setup.ts liegt im include von
apps/web/tsconfig.json, mit einem absichtlichen Fehler nachgewiesen).
BEFUND 4 (D-03, gemeldet, NICHT repariert) imap.provider.ts:78 - der
Ausdruck (node as any).disposition?.parameters?.filename liest .parameters
von einer ZEICHENKETTE: imapflow deklariert disposition als string
(imap-flow.d.ts:448), die Parameter liegen in dispositionParameters (:450).
dispositionFilename ist damit zur Laufzeit immer ''. Folge: Outlook-Anhaenge,
die als application/octet-stream kommen, werden ueber den Dateinamen aus
Content-Disposition NICHT erkannt - nur ueber den aus Content-Type. Umbiegen
waere eine Verhaltensaenderung; die Zusicherung bleibt sichtbar stehen.
BEFUND 5 (D-03, gemeldet, NICHT repariert) imap.provider.ts:402 -
requireTLS kommt in imapflow 1.4.3 NIRGENDS vor, weder in ImapFlowOptions
noch im Laufzeitcode (beides durchsucht). Die Option wird still verworfen;
STARTTLS wird durch sie nicht erzwungen. Genau das } as any hat es
verdeckt. Bleibt stehen, damit der Befund in der Zaehlung sichtbar ist.
BEFUND 6 (D-03, gemeldet, Verhalten unveraendert) httpntlm liefert den
Rumpf als Zeichenkette, nicht als Buffer: httpreq setzt ihn nur bei
gesetzter Option binary auf Buffer (httpreq@1.1.1/lib/httpreq.js:391),
keiner der beiden Aufrufer setzt sie. Der Bestand rief unbesehen
.toString('utf-8') auf - das ging nur gut, weil String.toString() sein
Argument ignoriert. Die Testdoppel reichen dagegen wirklich Buffer herein.
NtlmResponse.body nennt jetzt beide Formen, die Fallunterscheidung liefert
fuer jede exakt dasselbe Ergebnis wie zuvor.
Urteil BLEIBT mit Begruendung im Code an allen 15 verbleibenden Stellen:
3x addCronJob (require-Umweg aus 07-04), 5x node-forge (EC-Zweig und
extensions: any[] sind in @types/node-forge nicht beschrieben, 2x null as
any wo die Typen die Bibliothek nachweislich falsch beschreiben), 2x
imap-Befunde oben, 2x tx: any plus 2x Gefolge (Aufgabe 1), 1x
disposition-Befund.
noExplicitAny in apps/api/src: 31 -> 15 (Ausgang 288, Schranke 45), apps/web
1 -> 0. type-check 4/4, lint 5/5 (0 error), apps/api 72/1143, apps/web
73/531, rls-access-inventory 30/30. noNonNullAssertion 56, as unknown as 33,
ts-expect-error/ts-ignore 0/0, Unterdrueckungsmarker 1. biome.json, alle
package.json und pnpm-lock.yaml unveraendert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
229 lines
11 KiB
TypeScript
229 lines
11 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
|
|
// 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<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);
|
|
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<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';
|
|
}
|