diff --git a/apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts b/apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts new file mode 100644 index 0000000..cf8e05c --- /dev/null +++ b/apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TenderFingerprintBackfillService } from './tender-fingerprint-backfill.service'; +import { tenderFingerprint } from './tender-fingerprint'; + +/** + * TenderFingerprintBackfillService.spec — WINDOWS.md #11 Task 3. Hand- + * rolled fake PrismaService (Map), matching the project convention + * (tender-dedup.service.spec.ts, ldap-config.service.spec.ts). No real DB + * I/O — the real-DB recompute is proven separately by the plan's verify + * step against the local database. + */ + +function isOutdated(fingerprint: string | null): boolean { + return fingerprint === null || !fingerprint.startsWith('v2:'); +} + +function makeFakePrisma(seedRows: any[]) { + const tenders = new Map(seedRows.map((r) => [r.id, { ...r }])); + + const findManyOutdated = vi.fn(async ({ select, take }: any) => { + const rows = [...tenders.values()].filter((t) => isOutdated(t.fingerprint)); + const page = rows.slice(0, take); + return page.map((row) => { + const out: any = {}; + for (const k of Object.keys(select)) out[k] = row[k]; + return out; + }); + }); + + const update = vi.fn(async ({ where, data }: any) => { + const existing = tenders.get(where.id); + const updated = { ...existing, ...data }; + tenders.set(where.id, updated); + return updated; + }); + + const transaction = vi.fn(async (ops: Promise[]) => Promise.all(ops)); + + const prisma = { + tender: { findMany: findManyOutdated, update }, + $transaction: transaction, + __store: { tenders }, + }; + + return prisma; +} + +describe('TenderFingerprintBackfillService', () => { + it('eine Datenbank ohne Zeilen loest keine Schreiboperation aus und der Haken wirft nicht', async () => { + const prisma = makeFakePrisma([]); + const service = new TenderFingerprintBackfillService(prisma as any); + + await expect(service.onApplicationBootstrap()).resolves.not.toThrow(); + expect(prisma.tender.update).not.toHaveBeenCalled(); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('Zeilen mit altem Hash und Zeilen mit leerem Hash werden beide erfasst und tragen danach die neue Marke', async () => { + const prisma = makeFakePrisma([ + { + id: 't-old', + buyerName: 'Stadt Altstadt', + title: 'Sanierung Rathaus', + deadlineAt: null, + fingerprint: 'oldhashvalue', + }, + { + id: 't-null', + buyerName: 'Stadt Neustadt', + title: 'Neubau Schule', + deadlineAt: null, + fingerprint: null, + }, + { + id: 't-current', + buyerName: 'Stadt Gegenwart', + title: 'Bereits aktuell', + deadlineAt: null, + fingerprint: 'v2:already-current', + }, + ]); + const service = new TenderFingerprintBackfillService(prisma as any); + + const updated = await service.backfillOutdated(); + + expect(updated).toBe(2); + const rowOld = prisma.__store.tenders.get('t-old'); + const rowNull = prisma.__store.tenders.get('t-null'); + const rowCurrent = prisma.__store.tenders.get('t-current'); + expect(rowOld.fingerprint).toBe( + tenderFingerprint({ buyerName: 'Stadt Altstadt', title: 'Sanierung Rathaus', deadlineAt: null }), + ); + expect(rowNull.fingerprint).toBe( + tenderFingerprint({ buyerName: 'Stadt Neustadt', title: 'Neubau Schule', deadlineAt: null }), + ); + // Unangetastet — trug bereits die neue Marke. + expect(rowCurrent.fingerprint).toBe('v2:already-current'); + }); + + it('ein zweiter Lauf direkt danach schreibt nichts mehr', async () => { + const prisma = makeFakePrisma([ + { + id: 't-old', + buyerName: 'Stadt Altstadt', + title: 'Sanierung Rathaus', + deadlineAt: null, + fingerprint: 'oldhashvalue', + }, + ]); + const service = new TenderFingerprintBackfillService(prisma as any); + + const first = await service.backfillOutdated(); + expect(first).toBe(1); + + prisma.tender.update.mockClear(); + const second = await service.backfillOutdated(); + + expect(second).toBe(0); + expect(prisma.tender.update).not.toHaveBeenCalled(); + }); + + it('wirft die Datenbankabfrage, wirft der Haken trotzdem nicht', async () => { + const prisma = { + tender: { + findMany: vi.fn().mockRejectedValue(new Error('DB weg')), + update: vi.fn(), + }, + $transaction: vi.fn(), + }; + const service = new TenderFingerprintBackfillService(prisma as any); + + await expect(service.onApplicationBootstrap()).resolves.not.toThrow(); + expect(prisma.tender.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/tenders/tender-fingerprint-backfill.service.ts b/apps/api/src/tenders/tender-fingerprint-backfill.service.ts new file mode 100644 index 0000000..f355a9d --- /dev/null +++ b/apps/api/src/tenders/tender-fingerprint-backfill.service.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { FINGERPRINT_VERSION_PREFIX, tenderFingerprint } from './tender-fingerprint'; + +const PAGE_SIZE = 500; + +/** + * Grosszuegige Obergrenze an Durchlaeufen als Reissleine, nicht als + * funktionale Schranke: bei PAGE_SIZE=500 deckt das weit mehr als das + * gemessene Bestandsvolumen (16.255 Zeilen / 500 ~= 33 Seiten) ab. Greift + * nur, wenn ein unerwarteter Zustand (z.B. eine Zeile, die nie aus der + * Veraltet-Bedingung herausfaellt) sonst in eine Endlosschleife fuehren + * wuerde. + */ +const MAX_PAGES = 10_000; + +/** + * TenderFingerprintBackfillService — automatische Nachrechnung veralteter + * Fingerabdruecke beim Start (WINDOWS.md #11, Task 3 der v2-Formeländerung + * in tender-fingerprint.ts). + * + * Warum ueberhaupt automatisch: nach der Formelaenderung traegt jede + * Bestandszeile einen Hash der alten Formel und wuerde nie wieder auf einen + * neuen treffen. Die Produktionsdatenbank wird von Hand nicht angefasst, + * und ein Bootstrap-Schritt, den eine frische Installation still + * ueberspringt, ist in diesem Projekt bereits einmal als Fehlerbild + * aufgetreten (Poll-Cron, Projektnotiz "Tender-Cron Bootstrap"). Die + * Nachrechnung muss also von allein laufen, auf Bestand wie auf frischer + * Installation dasselbe Ergebnis liefern — und darf nicht bei jedem Start + * alle Zeilen anfassen. Genau dafuer ist FINGERPRINT_VERSION_PREFIX da. + * + * Gleiche Bauform wie die Nachverschluesselung der LDAP-Bind-Passwoerter in + * ldap-config.service.ts (dort das vorbildhafte Beispiel): implementiert + * OnApplicationBootstrap, injiziert PrismaService, und ein misslungener + * Lauf wird geloggt und geschluckt statt geworfen — ein fehlgeschlagener + * Nachlauf darf den Start des Dienstes nicht verhindern. + */ +@Injectable() +export class TenderFingerprintBackfillService implements OnApplicationBootstrap { + private readonly logger = new Logger(TenderFingerprintBackfillService.name); + + constructor(private readonly prisma: PrismaService) {} + + async onApplicationBootstrap(): Promise { + try { + const updated = await this.backfillOutdated(); + if (updated > 0) { + this.logger.log( + `Fingerabdruck-Nachrechnung: ${updated} Zeile(n) auf die neue Formel gebracht`, + ); + } + } catch (err) { + this.logger.error( + `Fingerabdruck-Nachrechnung fehlgeschlagen: ${(err as Error).message}`, + ); + } + } + + /** + * Rechnet alle Zeilen mit veraltetem oder fehlendem Fingerabdruck neu und + * gibt die Zahl der geaenderten Zeilen zurueck. Seitenweise (500 Zeilen), + * jede Seite gebuendelt in einer eigenen Transaktion geschrieben. + * + * Die Seite braucht keinen Versatz (kein `skip`): eine geschriebene Zeile + * faellt durch die neue Marke aus der Veraltet-Bedingung heraus, die + * naechste Abfrage liefert automatisch die naechsten Zeilen. Bricht ab, + * sobald eine Seite leer zurueckkommt. + */ + async backfillOutdated(): Promise { + let totalUpdated = 0; + + for (let page = 0; page < MAX_PAGES; page++) { + const rows = await this.prisma.tender.findMany({ + where: { + // Beide Faelle gehoeren in eine ODER-Verknuepfung: die + // "beginnt nicht mit"-Bedingung wird fuer NULL in SQL nicht wahr, + // leere Zeilen wuerden sonst durchrutschen. + OR: [ + { fingerprint: null }, + { NOT: { fingerprint: { startsWith: FINGERPRINT_VERSION_PREFIX } } }, + ], + }, + select: { id: true, buyerName: true, title: true, deadlineAt: true }, + take: PAGE_SIZE, + }); + + if (rows.length === 0) break; + + await this.prisma.$transaction( + rows.map((row) => + this.prisma.tender.update({ + where: { id: row.id }, + data: { fingerprint: tenderFingerprint(row) }, + }), + ), + ); + + totalUpdated += rows.length; + } + + return totalUpdated; + } +} diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index 69b95c0..62170bc 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -12,6 +12,7 @@ import { RssAdapter } from './adapters/rss.adapter'; import { SourceRegistry } from './source-registry'; import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed'; import { TenderDedupService } from './tender-dedup.service'; +import { TenderFingerprintBackfillService } from './tender-fingerprint-backfill.service'; import { TenderDigestScheduler } from './tender-digest.scheduler'; import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderIngestionService } from './tender-ingestion.service'; @@ -135,6 +136,12 @@ import { TendersController } from './tenders.controller'; TenderNormalizerService, TenderDedupService, TenderIngestionService, + // WINDOWS.md #11, Task 3: MUSS vor TenderSchedulerService stehen — Nest + // ruft die OnApplicationBootstrap-Haken eines Moduls in der Reihenfolge + // der Anbieterliste auf, und die Fingerabdruck-Nachrechnung soll fertig + // sein, bevor der Poll-Cron zu laufen beginnt. Beim Umsortieren dieser + // Liste bitte diese Reihenfolge erhalten. + TenderFingerprintBackfillService, TenderSchedulerService, TenderTriageService, TenderSavedSearchService,