fix(paperless): Anpassung an Paperless-NGX 3.4 (API v10)
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s

- Task-Status: getTask() normalisiert die paginierte v10-Antwort
  (status klein, result_data, related_document_ids). Bisher wurde jede
  Task als „nicht gefunden“ gelöscht, ohne Nachbearbeitung.
- Checksummen: Paperless ≥ 3.0 nutzt SHA-256 statt MD5. Duplikatprüfung
  umgestellt; Migration verbreitert Attachments.Checksum und berechnet
  Bestandswerte per SHA2() neu.
- Custom-Field-Suche über custom_field_query, der alte Filter wurde von
  Paperless ignoriert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-10-31 20:15:01 +01:00
1 parent 33457c19c2
commit 4f34855c12
10 files changed
+218 -40

No files matched your search

@@ -28,7 +28,8 @@ export class Attachment {
@Column({ type: 'tinyint', width: 1 }) @Column({ type: 'tinyint', width: 1 })
Erechnung!: boolean; Erechnung!: boolean;
@Column({ type: 'varchar', length: 32, default: '' }) /** SHA-256 der Datei — entspricht der Checksumme in Paperless ≥ 3.0 */
@Column({ type: 'varchar', length: 64, default: '' })
Checksum!: string; Checksum!: string;
/** /**
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Paperless-NGX ≥ 3.0 speichert Checksummen als SHA-256 statt MD5. Damit der
* Duplikat-Abgleich (`checksum__iexact`) wieder trifft, wird
* `Attachments.Checksum` auf 64 Zeichen verbreitert und für alle Anhänge mit
* gespeichertem Inhalt direkt in der Datenbank neu berechnet.
*/
export class AttachmentChecksumSha2561793500000000 implements MigrationInterface {
name = 'AttachmentChecksumSha2561793500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE \`Attachments\`
MODIFY \`Checksum\` varchar(64) NOT NULL DEFAULT ''
`);
await queryRunner.query(`
UPDATE \`Attachments\` a
INNER JOIN \`Contents\` c ON c.\`AttachmentEntityId\` = a.\`Id\`
SET a.\`Checksum\` = SHA2(c.\`Content\`, 256)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE \`Attachments\` a
INNER JOIN \`Contents\` c ON c.\`AttachmentEntityId\` = a.\`Id\`
SET a.\`Checksum\` = MD5(c.\`Content\`)
`);
await queryRunner.query(`
ALTER TABLE \`Attachments\`
MODIFY \`Checksum\` varchar(32) NOT NULL DEFAULT ''
`);
}
}
@@ -304,7 +304,10 @@ export class EmailDownloadService {
attachment.ContentType = contentType.slice(0, 100); attachment.ContentType = contentType.slice(0, 100);
attachment.IsEmbedded = isEmbedded; attachment.IsEmbedded = isEmbedded;
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null; attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex'); attachment.Checksum = crypto
.createHash('sha256')
.update(buffer)
.digest('hex');
attachment.ContentFingerprint = attachment.ContentFingerprint =
contentType.toLowerCase() === 'application/pdf' contentType.toLowerCase() === 'application/pdf'
? await computeContentFingerprint(buffer) ? await computeContentFingerprint(buffer)
@@ -184,7 +184,7 @@ describe('EmailImportService – Duplikatprüfung', () => {
const [checksum] = paperlessService.checksumExists.mock const [checksum] = paperlessService.checksumExists.mock
.calls[0] as string[]; .calls[0] as string[];
expect(checksum).toMatch(/^[0-9a-f]{32}$/); expect(checksum).toMatch(/^[0-9a-f]{64}$/);
expect(checksum).not.toBe('abc123'); expect(checksum).not.toBe('abc123');
}); });
}); });
@@ -307,8 +307,9 @@ export class EmailImportService {
const copied = await sliced.copyPages(pdfDoc, indices); const copied = await sliced.copyPages(pdfDoc, indices);
copied.forEach((p) => sliced.addPage(p)); copied.forEach((p) => sliced.addPage(p));
// Paperless ≥ 3.0 vergleicht SHA-256-Checksummen
const checksum = crypto const checksum = crypto
.createHash('md5') .createHash('sha256')
.update(Buffer.from(await sliced.save())) .update(Buffer.from(await sliced.save()))
.digest('hex'); .digest('hex');
return this.paperlessService.checksumExists(checksum); return this.paperlessService.checksumExists(checksum);
@@ -787,14 +788,17 @@ export class EmailImportService {
try { try {
const taskStatus = const taskStatus =
await this.paperlessService.getTask(paperlessTaskId); await this.paperlessService.getTask(paperlessTaskId);
// Paperless returns { results: [ ... ] } for filtered tasks if (taskStatus?.documentId) {
const statusObj = taskStatus.results docId = taskStatus.documentId;
? taskStatus.results[0] break;
: Array.isArray(taskStatus) }
? taskStatus[0] if (
: taskStatus; taskStatus?.status === 'FAILURE' ||
if (statusObj && statusObj.related_document) { taskStatus?.status === 'REVOKED'
docId = statusObj.related_document; ) {
this.logger.warn(
`Paperless-Task ${paperlessTaskId} fehlgeschlagen: ${taskStatus.message ?? 'keine Meldung'}`,
);
break; break;
} }
} catch (e) {} } catch (e) {}
@@ -483,7 +483,8 @@ export class InboxPostprocessorService {
// 6. Checksum berechnen und prüfen // 6. Checksum berechnen und prüfen
const buffer = await fs.readFile(pdfPath); const buffer = await fs.readFile(pdfPath);
const checksum = crypto.createHash('md5').update(buffer).digest('hex'); // Paperless ≥ 3.0 vergleicht SHA-256-Checksummen
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const checksumExists = const checksumExists =
await this.paperlessService.checksumExists(checksum); await this.paperlessService.checksumExists(checksum);
if (checksumExists) { if (checksumExists) {
@@ -64,14 +64,14 @@ describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
/** Zugriff auf die private Methode, typsicher für den Test gekapselt. */ /** Zugriff auf die private Methode, typsicher für den Test gekapselt. */
function runProcessSuccessfulTask( function runProcessSuccessfulTask(
task: Task, task: Task,
apiTask: { related_document: number }, apiTask: { documentId: number },
parentTask: Task | null, parentTask: Task | null,
): Promise<void> { ): Promise<void> {
return ( return (
service as unknown as { service as unknown as {
processSuccessfulTask: ( processSuccessfulTask: (
t: Task, t: Task,
a: { related_document: number }, a: { documentId: number },
p: Task | null, p: Task | null,
) => Promise<void>; ) => Promise<void>;
} }
@@ -132,7 +132,7 @@ describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
CustomFieldsJson: JSON.stringify({ '4': 'x' }), CustomFieldsJson: JSON.stringify({ '4': 'x' }),
}); });
await runProcessSuccessfulTask(task, { related_document: 101 }, null); await runProcessSuccessfulTask(task, { documentId: 101 }, null);
expect(paperlessService.updateDocument).toHaveBeenCalledTimes(1); expect(paperlessService.updateDocument).toHaveBeenCalledTimes(1);
expect(paperlessService.updateDocument).toHaveBeenCalledWith( expect(paperlessService.updateDocument).toHaveBeenCalledWith(
@@ -164,7 +164,7 @@ describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
}); });
const task = createTask({ InterneBelegnummer: '2026-000124' }); const task = createTask({ InterneBelegnummer: '2026-000124' });
await runProcessSuccessfulTask(task, { related_document: 102 }, null); await runProcessSuccessfulTask(task, { documentId: 102 }, null);
expect(lastPatchPayload().owner).toBeNull(); expect(lastPatchPayload().owner).toBeNull();
}); });
@@ -178,7 +178,7 @@ describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
tags: [], tags: [],
}) })
.mockResolvedValueOnce({ id: 55, title: 'Eltern' }); .mockResolvedValueOnce({ id: 55, title: 'Eltern' });
paperlessService.getTask.mockResolvedValue([{ related_document: 55 }]); paperlessService.getTask.mockResolvedValue({ documentId: 55 });
const task = createTask({ const task = createTask({
DocumentType: 2, DocumentType: 2,
TaskReferenceID: 'parent-uuid', TaskReferenceID: 'parent-uuid',
@@ -189,7 +189,7 @@ describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
Fertig: 1, Fertig: 1,
}); });
await runProcessSuccessfulTask(task, { related_document: 103 }, parentTask); await runProcessSuccessfulTask(task, { documentId: 103 }, parentTask);
const payload = lastPatchPayload(); const payload = lastPatchPayload();
expect(payload.document_type).toBe(5); expect(payload.document_type).toBe(5);
@@ -5,7 +5,10 @@ import { Repository, IsNull } from 'typeorm';
import { Task } from '../database/entities/task.entity'; import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity'; import { Document } from '../database/entities/document.entity';
import { Attachment } from '../database/entities/attachment.entity'; import { Attachment } from '../database/entities/attachment.entity';
import { PaperlessService } from './paperless.service'; import {
PaperlessService,
type PaperlessTaskStatus,
} from './paperless.service';
import { deriveTaskMetadata } from './task-metadata.util'; import { deriveTaskMetadata } from './task-metadata.util';
@Injectable() @Injectable()
@@ -58,15 +61,12 @@ export class PaperlessTaskProcessorService {
} }
// Fetch task status from Paperless // Fetch task status from Paperless
const paperlessTasks = await this.paperlessService.getTask(t.TaskId); const apiResponseTask = await this.paperlessService.getTask(t.TaskId);
const apiResponseTask = Array.isArray(paperlessTasks)
? paperlessTasks[0]
: null;
if (apiResponseTask) { if (apiResponseTask) {
if (apiResponseTask.status === 'SUCCESS') { if (apiResponseTask.status === 'SUCCESS') {
const dateDone = apiResponseTask.date_done const dateDone = apiResponseTask.dateDone
? new Date(apiResponseTask.date_done) ? new Date(apiResponseTask.dateDone)
: new Date(); : new Date();
const now = new Date(); const now = new Date();
@@ -74,12 +74,15 @@ export class PaperlessTaskProcessorService {
if (dateDone.getTime() + 10000 < now.getTime()) { if (dateDone.getTime() + 10000 < now.getTime()) {
await this.processSuccessfulTask(t, apiResponseTask, parentTask); await this.processSuccessfulTask(t, apiResponseTask, parentTask);
} }
} else if (apiResponseTask.status === 'FAILURE') { } else if (
apiResponseTask.status === 'FAILURE' ||
apiResponseTask.status === 'REVOKED'
) {
// Seit Metadaten (inkl. ASN) direkt beim Upload gesetzt werden, kann // Seit Metadaten (inkl. ASN) direkt beim Upload gesetzt werden, kann
// z.B. eine ASN-Kollision bereits den Consume scheitern lassen – // z.B. eine ASN-Kollision bereits den Consume scheitern lassen –
// vor dem Löschen die Diagnose-Infos festhalten // vor dem Löschen die Diagnose-Infos festhalten
this.logger.error( this.logger.error(
`Task ${t.TaskId} in Paperless fehlgeschlagen (Beleg: ${t.InterneBelegnummer || '-'}, Attachment: ${t.SourceAttachmentID ?? '-'}). Paperless-Meldung: ${apiResponseTask.result ?? 'keine'}`, `Task ${t.TaskId} in Paperless fehlgeschlagen (Beleg: ${t.InterneBelegnummer || '-'}, Attachment: ${t.SourceAttachmentID ?? '-'}). Paperless-Meldung: ${apiResponseTask.message ?? 'keine'}`,
); );
toDelete.push(t); toDelete.push(t);
} }
@@ -103,10 +106,10 @@ export class PaperlessTaskProcessorService {
private async processSuccessfulTask( private async processSuccessfulTask(
t: Task, t: Task,
apiTask: any, apiTask: PaperlessTaskStatus,
parentTask: Task | null, parentTask: Task | null,
) { ) {
const documentId = apiTask.related_document; const documentId = apiTask.documentId;
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} gestartet. DocumentID: ${documentId ?? 'nicht vorhanden'}`, `[Postprocessing] Task ${t.TaskId} gestartet. DocumentID: ${documentId ?? 'nicht vorhanden'}`,
); );
@@ -241,16 +244,13 @@ export class PaperlessTaskProcessorService {
this.logger.log( this.logger.log(
`[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`, `[Postprocessing] Task ${t.TaskId} - Verarbeite als Anlage zu ParentTask ${parentTask.TaskId}`,
); );
const parentPaperlessTasks = await this.paperlessService.getTask( const apiParentTask = await this.paperlessService.getTask(
parentTask.TaskId, parentTask.TaskId,
); );
const apiParentTask = Array.isArray(parentPaperlessTasks)
? parentPaperlessTasks[0]
: null;
if (apiParentTask && apiParentTask.related_document) { if (apiParentTask?.documentId) {
const parentDoc = await this.paperlessService.getDocument( const parentDoc = await this.paperlessService.getDocument(
apiParentTask.related_document, apiParentTask.documentId,
); );
if (parentDoc) { if (parentDoc) {
this.logger.log( this.logger.log(
@@ -0,0 +1,76 @@
import { normalizePaperlessTask } from './paperless.service';
describe('normalizePaperlessTask', () => {
it('API v10: erfolgreicher Consume liefert documentId aus result_data', () => {
const t = normalizePaperlessTask({
task_id: 'abc',
task_type: 'consume_file',
status: 'success',
date_done: '2026-10-30T16:16:00.091045+01:00',
result_data: { document_id: 4711 },
related_document_ids: [4711],
});
expect(t).toEqual({
taskId: 'abc',
status: 'SUCCESS',
documentId: 4711,
duplicateOf: null,
message: null,
dateDone: '2026-10-30T16:16:00.091045+01:00',
});
});
it('API v10: Duplikat ist FAILURE ohne documentId, mit duplicateOf', () => {
const t = normalizePaperlessTask({
task_id: 'abc',
status: 'failure',
result_data: { duplicate_of: 12 },
related_document_ids: [12],
});
expect(t.status).toBe('FAILURE');
expect(t.documentId).toBeNull();
expect(t.duplicateOf).toBe(12);
expect(t.message).toContain('12');
});
it('API v10: Fehler übernimmt error_message', () => {
const t = normalizePaperlessTask({
task_id: 'abc',
status: 'failure',
result_data: {
error_type: 'ConsumerError',
error_message: 'ASN existiert',
},
related_document_ids: [],
});
expect(t.status).toBe('FAILURE');
expect(t.documentId).toBeNull();
expect(t.message).toBe('ASN existiert');
});
it('API v10: laufende Task ohne Ergebnis', () => {
const t = normalizePaperlessTask({
task_id: 'abc',
status: 'started',
result_data: null,
related_document_ids: [],
date_done: null,
});
expect(t.status).toBe('STARTED');
expect(t.documentId).toBeNull();
expect(t.message).toBeNull();
});
it('Legacy-API v9: related_document und result', () => {
const t = normalizePaperlessTask({
task_id: 'abc',
status: 'SUCCESS',
result: 'Success. New document id 55 created',
related_document: '55',
date_done: '2026-10-30T16:16:00+01:00',
});
expect(t.status).toBe('SUCCESS');
expect(t.documentId).toBe(55);
expect(t.message).toBe('Success. New document id 55 created');
});
});
@@ -5,6 +5,55 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import FormData = require('form-data'); import FormData = require('form-data');
export interface PaperlessTaskStatus {
taskId: string;
status: 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE' | 'REVOKED';
/** Neu angelegtes Dokument (nur bei Erfolg) */
documentId: number | null;
/** Bereits vorhandenes Dokument, falls Paperless ein Duplikat abgelehnt hat */
duplicateOf: number | null;
/** Fehlermeldung bzw. Ergebnistext */
message: string | null;
dateDone: string | null;
}
/**
* Bildet eine Task aus API v10 (`result_data`, `related_document_ids`) oder
* der Legacy-API v9 (`result`, `related_document`) auf PaperlessTaskStatus ab.
*/
export function normalizePaperlessTask(raw: any): PaperlessTaskStatus {
const resultData =
raw.result_data && typeof raw.result_data === 'object'
? raw.result_data
: null;
const toId = (v: unknown): number | null =>
v === null || v === undefined || v === '' ? null : Number(v);
const duplicateOf = toId(resultData?.duplicate_of);
const documentId =
toId(resultData?.document_id) ??
toId(raw.related_document) ??
(duplicateOf === null ? toId(raw.related_document_ids?.[0]) : null);
let message: string | null = null;
if (resultData?.error_message) message = String(resultData.error_message);
else if (duplicateOf !== null)
message = `Duplikat von Dokument ${duplicateOf}`;
else if (typeof raw.result_data === 'string') message = raw.result_data;
else if (raw.result != null) message = String(raw.result);
return {
taskId: raw.task_id,
status: String(
raw.status ?? '',
).toUpperCase() as PaperlessTaskStatus['status'],
documentId,
duplicateOf,
message,
dateDone: raw.date_done ?? null,
};
}
@Injectable() @Injectable()
export class PaperlessService { export class PaperlessService {
private readonly logger = new Logger(PaperlessService.name); private readonly logger = new Logger(PaperlessService.name);
@@ -175,11 +224,19 @@ export class PaperlessService {
return response.data.results; return response.data.results;
} }
async getTask(taskId: string): Promise<any> { /**
* Liefert den Status einer Paperless-Task oder null, falls unbekannt.
* Paperless ≥ 3.0 (API v10) antwortet paginiert mit kleingeschriebenem
* Status, `result_data` und `related_document_ids` — hier auf ein festes
* Format normalisiert, damit die Aufrufer unabhängig von der API-Version sind.
*/
async getTask(taskId: string): Promise<PaperlessTaskStatus | null> {
const response = await this.client.get('/tasks/', { const response = await this.client.get('/tasks/', {
params: { task_id: taskId }, params: { task_id: taskId },
}); });
return response.data; const data = response.data;
const raw = Array.isArray(data) ? data[0] : data?.results?.[0];
return raw ? normalizePaperlessTask(raw) : null;
} }
async getCorrespondentByName(name: string): Promise<any> { async getCorrespondentByName(name: string): Promise<any> {
@@ -311,7 +368,8 @@ export class PaperlessService {
/** /**
* Liefert die Paperless-Doc-ID des passenden Dokuments oder null. * Liefert die Paperless-Doc-ID des passenden Dokuments oder null.
* Paperless kann den custom_fields-Filter ignorieren — daher manuell verifizieren. * Filtert serverseitig per `custom_field_query`; die Treffer werden zusätzlich
* case-insensitiv verifiziert (Paperless kennt für Textfelder kein `iexact`).
*/ */
async findDocumentIdByCustomField( async findDocumentIdByCustomField(
fieldId: number, fieldId: number,
@@ -319,7 +377,7 @@ export class PaperlessService {
): Promise<number | null> { ): Promise<number | null> {
const response = await this.client.get('/documents/', { const response = await this.client.get('/documents/', {
params: { params: {
[`custom_fields__${fieldId}__value__iexact`]: value, custom_field_query: JSON.stringify([fieldId, 'icontains', value]),
page_size: 25, page_size: 25,
truncate_content: true, truncate_content: true,
}, },