import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Brackets, Repository } from 'typeorm'; import { Attachment } from '../database/entities/attachment.entity'; import { Content } from '../database/entities/content.entity'; import { computeContentFingerprint } from './pdf-fingerprint.util'; /** * Verwaltet den inhaltsbasierten Fingerprint der Mail-Anhänge: berechnet ihn * bei Bedarf nach, füllt ihn für Altbestände im Hintergrund auf und findet * bereits importierte Anhänge mit gleichem Inhalt. */ @Injectable() export class AttachmentFingerprintService implements OnApplicationBootstrap { private readonly logger = new Logger(AttachmentFingerprintService.name); constructor( @InjectRepository(Attachment) private readonly attachmentRepo: Repository, @InjectRepository(Content) private readonly contentRepo: Repository, ) {} onApplicationBootstrap(): void { // Nicht auf den Backfill warten – der Start soll nicht blockieren this.backfill().catch((err: Error) => this.logger.error(`Fingerprint-Backfill fehlgeschlagen: ${err.message}`), ); } /** * Liefert den Fingerprint eines Anhangs und berechnet ihn nach, falls er * noch fehlt (z.B. Mail vor dem Backfill eingegangen). `null`, wenn kein * vergleichbarer Text vorliegt. */ async ensure(attachment: Attachment): Promise { if (attachment.ContentFingerprint != null) { return attachment.ContentFingerprint || null; } if (attachment.ContentType !== 'application/pdf') return null; const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachment.Id }, }); if (!content) return null; const fingerprint = await computeContentFingerprint(content.Content1); if (fingerprint === null) return null; await this.attachmentRepo.update(attachment.Id, { ContentFingerprint: fingerprint, }); attachment.ContentFingerprint = fingerprint; return fingerprint || null; } /** * Gibt es einen anderen Anhang mit gleichem Inhalt, dessen Seitenbereich * bereits importiert wurde oder gerade importiert wird? */ async hasImportedTwin( attachmentId: number, fingerprint: string, range: string, ): Promise { const count = await this.attachmentRepo .createQueryBuilder('a') .where('a.ContentFingerprint = :fingerprint', { fingerprint }) .andWhere('a.Id != :attachmentId', { attachmentId }) .andWhere( new Brackets((qb) => { // Abgeschlossener Import mit ermittelter Paperless-ID qb.where( "JSON_CONTAINS_PATH(a.PaperlessDocumentIds, 'one', :jsonPath) = 1", { jsonPath: `$."${range}"` }, ) // Laufender oder abgeschlossener Upload (fehlgeschlagene Tasks // löscht der Task-Processor) .orWhere( 'EXISTS (SELECT 1 FROM tasks t WHERE t.SourceAttachmentID = a.Id AND t.SourceAttachmentRange = :range)', { range }, ); // Ganzer Anhang: jeder Import zählt, auch wenn die ID beim Upload // nicht ermittelt werden konnte if (range === 'full') qb.orWhere('a.ImportStatus = 1'); }), ) .getCount(); return count > 0; } /** * Sucht einen bereits importierten Anhang mit gleichem Inhalt und liefert * dessen Paperless-Dokument-ID (sofern beim Import ermittelt). */ async findImportedTwin( attachment: Attachment, ): Promise<{ attachmentId: number; documentId: number | null } | null> { const fingerprint = await this.ensure(attachment); if (!fingerprint) return null; const twin = await this.attachmentRepo .createQueryBuilder('a') .where('a.ContentFingerprint = :fingerprint', { fingerprint }) .andWhere('a.Id != :attachmentId', { attachmentId: attachment.Id }) .andWhere('a.ImportStatus = 1') .orderBy('a.Id', 'ASC') .getOne(); if (!twin) return null; const ids = twin.PaperlessDocumentIds as Record | null; return { attachmentId: twin.Id, documentId: ids?.full ?? null }; } /** Berechnet fehlende Fingerprints aller PDF-Anhänge in kleinen Portionen. */ async backfill(batchSize = 25): Promise { let lastId = 0; let updated = 0; for (;;) { const batch = await this.attachmentRepo .createQueryBuilder('a') .select(['a.Id', 'a.ContentType', 'a.ContentFingerprint']) .where('a.ContentFingerprint IS NULL') .andWhere("a.ContentType = 'application/pdf'") .andWhere('a.Id > :lastId', { lastId }) .orderBy('a.Id', 'ASC') .take(batchSize) .getMany(); if (batch.length === 0) break; for (const attachment of batch) { // lastId weiterschieben, damit unlesbare PDFs nicht endlos drankommen lastId = attachment.Id; try { if ((await this.ensure(attachment)) !== null) updated++; } catch (err) { this.logger.warn( `Fingerprint für Anhang ${attachment.Id} nicht berechenbar: ${(err as Error).message}`, ); } } } if (updated > 0) { this.logger.log(`Fingerprint-Backfill: ${updated} Anhänge ergänzt.`); } return updated; } }