Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 changed files with 67739 additions and 0 deletions
@@ -0,0 +1,432 @@
import { Controller, Get, Param, Post, Put, Delete, UseGuards, UseInterceptors, UploadedFile, Body, Logger, HttpException, HttpStatus, Res, Query } from '@nestjs/common';
import type { Response } from 'express';
import { Public } from '../auth/public.decorator';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
import { FileInterceptor } from '@nestjs/platform-express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PaperlessService } from './paperless.service';
import { ApiKeyGuard } from '../auth/api-key.guard';
import { UploadExternalDto } from './dto/upload-external.dto';
import { Task } from '../database/entities/task.entity';
import { Document } from '../database/entities/document.entity';
import { DocumentField } from '../database/entities/document-field.entity';
import { DocumentType } from '../database/entities/document-type.entity';
@Controller('api/paperless')
export class PaperlessController {
private readonly logger = new Logger(PaperlessController.name);
constructor(
private readonly paperlessService: PaperlessService,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@InjectRepository(Document)
private readonly documentRepo: Repository<Document>,
@InjectRepository(DocumentField)
private readonly documentFieldRepo: Repository<DocumentField>,
@InjectRepository(DocumentType)
private readonly documentTypeRepo: Repository<DocumentType>,
) {}
@Post('checksum')
async checksumExists(@Body('checksum') checksum: string) {
const exists = await this.paperlessService.checksumExists(checksum);
return { exists };
}
@Get('documents')
async getDocuments(
@Query('search') search?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const params: any = {
page: page || '1',
page_size: pageSize || '20',
};
if (search) {
params.query = search; // Global search in Paperless
}
return this.paperlessService.getDocuments(params);
}
@Get('documents/:id')
async getDocument(@Param('id') id: string) {
return this.paperlessService.getDocument(parseInt(id, 10));
}
@Get('tags')
async getTags() {
return this.paperlessService.getTags();
}
@Get('tags/:id')
async getTag(@Param('id') id: string) {
// If the service doesn't have getTag(id), I should add it or just fetch all and find
const tags = await this.paperlessService.getTags();
return tags.find(t => t.id === parseInt(id, 10));
}
@Get('document-types')
async getDocumentTypes() {
return this.paperlessService.getDocumentTypes();
}
@Get('custom-fields')
async getCustomFields() {
return this.paperlessService.getCustomFields();
}
@Get('users')
async getUsers() {
return this.paperlessService.getUsers();
}
@Get('correspondents')
async getCorrespondents(@Query('search') search?: string) {
const params: any = { page_size: 100 };
if (search) {
params.name__icontains = search;
}
const response = await this.paperlessService.getCorrespondents(params);
return response.results;
}
@Get('correspondents/:id')
async getCorrespondent(@Param('id') id: string) {
return this.paperlessService.getCorrespondent(parseInt(id, 10));
}
@Get('inbox/list')
@RequirePermissions(Permission.VIEW_INBOX)
async getInboxList() {
const documents = await this.paperlessService.getInboxDocuments();
// In old C# logic: only return docs where archive_serial_number is not null
return documents
.filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined)
.map((doc: any) => ({
id: doc.id,
title: doc.title,
asn: doc.archive_serial_number,
documentType: doc.document_type,
correspondent: doc.correspondent,
created: doc.created_date,
added: doc.added,
tags: doc.tags,
customFields: doc.custom_fields,
owner: doc.owner,
}));
}
@Get('manuell/list')
@RequirePermissions(Permission.PROCESS_MANUALLY)
async getManuellList() {
const documents = await this.paperlessService.getManuellDocuments();
return documents
.filter((doc: any) => doc.archive_serial_number !== null && doc.archive_serial_number !== undefined)
.map((doc: any) => ({
id: doc.id,
title: doc.title,
asn: doc.archive_serial_number,
documentType: doc.document_type,
correspondent: doc.correspondent,
created: doc.created_date,
added: doc.added,
tags: doc.tags,
customFields: doc.custom_fields,
owner: doc.owner,
}));
}
@Public()
@Get('inbox/preview/:id')
async getInboxPreview(@Param('id') id: string, @Res() res: Response) {
try {
const stream = await this.paperlessService.getDocumentPreviewStream(parseInt(id, 10));
res.set({
'Content-Type': 'image/png',
'Content-Disposition': `inline; filename="${id}preview.png"`,
});
stream.pipe(res);
} catch (error) {
if (!res.headersSent) {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Error fetching preview');
}
}
}
@Public()
@Get('inbox/pdf/:id')
async getInboxPdf(@Param('id') id: string, @Res() res: Response) {
try {
const stream = await this.paperlessService.getDocumentPdfStream(parseInt(id, 10));
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${id}.pdf"`,
});
stream.pipe(res);
} catch (error) {
if (!res.headersSent) {
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send('Error fetching PDF');
}
}
}
@Get('requirements/:id')
async getRequirements(@Param('id') id: string, @Query('Posteingang') posteingang: string) {
const documentTypeId = parseInt(id, 10);
const isPosteingang = posteingang === '1';
const requirements = await this.documentFieldRepo.find({
where: { DocumentType: documentTypeId },
});
// Custom fields fetching inside here could be slow, but this is the simplest translation of the old API
// Actually, getting all CFs doesn't take too long in Paperless API.
const customFields = await this.paperlessService.getCustomFields();
const retVal: any[] = [];
for (const req of requirements) {
if (isPosteingang && !req.VisiblePosteingang) {
continue;
}
const tmp: any = {
id: req.Id,
feldId: req.Type + (req.TypeIndex !== null ? '-' + req.TypeIndex : ''),
hinweis: req.Hinweis || '',
required: isPosteingang ? req.IsRequiredPosteingang : req.IsRequired,
};
if (req.Type === 4) {
// Custom Field
const cfId = Number(req.TypeIndex);
const cf = customFields.find((c: any) => c.id === cfId);
tmp.isCustomField = true;
tmp.customFieldIndex = cfId;
if (cf) {
tmp.feldName = cf.name;
tmp.feldTyp = cf.id === 12 ? 'int' : cf.data_type;
if (cf.extra_data && cf.extra_data.select_options) {
tmp.fieldOptions = cf.extra_data.select_options
.filter((o: any) => o !== null)
.map((o: any) => ({ id: o.id, label: o.label }));
}
} else {
tmp.feldName = `Feld ${req.TypeIndex}`;
tmp.feldTyp = 'string';
}
} else if (req.Type === 1) {
tmp.feldName = 'Absender';
tmp.feldTyp = 'select';
const response = await this.paperlessService.getCorrespondents({ page_size: 9999 });
const correspondents = response.results;
tmp.fieldOptions = correspondents.map((c: any) => ({ id: c.id.toString(), label: c.name }));
} else if (req.Type === 2) {
tmp.feldName = 'Belegdatum';
tmp.feldTyp = 'date';
} else if (req.Type === 3) {
tmp.feldName = 'Ablagenummer';
tmp.feldTyp = 'string';
} else if (req.Type === 5) {
tmp.feldName = 'Titel';
tmp.feldTyp = 'string';
} else {
tmp.feldName = tmp.feldId;
tmp.feldTyp = 'string';
}
retVal.push(tmp);
}
return retVal;
}
@Put('inbox/:id')
async putInboxDocument(@Param('id') id: string, @Body() body: any) {
const documentId = parseInt(id, 10);
// Fetch from paperless
const oldDocument = await this.paperlessService.getDocument(documentId);
oldDocument.document_type = body.documentType ?? oldDocument.document_type;
oldDocument.owner = body.mandant ?? oldDocument.owner;
oldDocument.correspondent = body.correspondent ?? oldDocument.correspondent;
oldDocument.title = body.title ?? oldDocument.title;
if (body.date) {
let docDate = new Date(body.date);
if (docDate.getHours() > 22) {
docDate = new Date(docDate.getTime() + 24 * 60 * 60 * 1000 - docDate.getHours() * 60 * 60 * 1000);
}
oldDocument.created_date = docDate.toISOString().split('T')[0];
}
const cfDefinitions = await this.paperlessService.getCustomFields();
// update custom fields
if (body.customFields) {
for (const [key, value] of Object.entries(body.customFields)) {
const fieldId = parseInt(key, 10);
const cfDef = cfDefinitions.find((c: any) => c.id === fieldId);
let processedValue = value;
if (cfDef?.data_type === 'documentlink' && value !== null && value !== '' && !Array.isArray(value)) {
processedValue = [value];
}
const existingFieldIndex = oldDocument.custom_fields.findIndex((f: any) => f.field === fieldId);
if (existingFieldIndex !== -1) {
if (processedValue === null || processedValue === '') {
oldDocument.custom_fields.splice(existingFieldIndex, 1);
} else {
oldDocument.custom_fields[existingFieldIndex].value = processedValue;
}
} else if (processedValue !== null && processedValue !== '') {
oldDocument.custom_fields.push({ field: fieldId, value: processedValue });
}
}
}
// Requirements check
const reqs = await this.documentFieldRepo.find({
where: { DocumentType: oldDocument.document_type },
});
let isReady = true;
let isReadyPosteingang = true;
for (const req of reqs) {
let isFieldValid = false;
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
if (req.Type === 2) isFieldValid = oldDocument.created_date !== null;
if (req.Type === 3) isFieldValid = oldDocument.archive_serial_number !== null;
if (req.Type === 4) isFieldValid = !!oldDocument.custom_fields.find((cf: any) => cf.field === req.TypeIndex && cf.value !== null && cf.value !== '');
if (req.Type === 5) isFieldValid = oldDocument.title !== null && oldDocument.title !== '';
if (req.IsRequired && !isFieldValid) isReady = false;
if (req.IsRequiredPosteingang && !isFieldValid) isReadyPosteingang = false;
}
const docType = await this.documentTypeRepo.findOne({
where: { DocumentTypeId: oldDocument.document_type },
});
oldDocument.tags = oldDocument.tags || [];
if (isReady) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
if (docType?.TagNotReady) oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagNotReady);
if (docType?.TagReady && !oldDocument.tags.includes(docType.TagReady)) {
oldDocument.tags.push(docType.TagReady);
}
let titleTemplate = docType?.TitelTemplate || '';
if (titleTemplate) {
for (const cf of oldDocument.custom_fields) {
const placeholder = `{{CUSTOM[${cf.field}]}}`;
if (titleTemplate.includes(placeholder)) {
titleTemplate = titleTemplate.replace(placeholder, cf.value?.toString() ?? '');
}
}
titleTemplate = titleTemplate.replace('{{DATE}}', oldDocument.created_date);
oldDocument.title = titleTemplate;
}
} else {
if (docType?.TagNotReady) {
if (isReadyPosteingang) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== 1);
if (!oldDocument.tags.includes(docType.TagNotReady)) oldDocument.tags.push(docType.TagNotReady);
} else {
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
}
} else {
if (!oldDocument.tags.includes(1)) oldDocument.tags.push(1);
}
if (docType?.TagReady) {
oldDocument.tags = oldDocument.tags.filter((t: number) => t !== docType.TagReady);
}
}
await this.paperlessService.updateDocument(documentId, oldDocument);
return { success: true };
}
@Get('tasks')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getAllTasks() {
return this.taskRepo.find({ order: { Fertig: 'ASC' } });
}
@Delete('tasks/fertig')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async deleteFertigeTasks() {
const result = await this.taskRepo.delete({ Fertig: 1 });
return { deleted: result.affected ?? 0 };
}
@Delete('tasks/:taskId')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async deleteTask(@Param('taskId') taskId: string) {
const result = await this.taskRepo.delete({ TaskId: taskId });
if (!result.affected) {
throw new HttpException('Task nicht gefunden', HttpStatus.NOT_FOUND);
}
return { deleted: result.affected };
}
@Post('external-upload')
@UseGuards(ApiKeyGuard)
@UseInterceptors(FileInterceptor('document'))
async externalUpload(
@UploadedFile() file: Express.Multer.File,
@Body() dto: UploadExternalDto,
) {
this.logger.log(`Externer Upload gestartet: ${dto.interneBelegnummer} (${file?.originalname})`);
try {
// 0. Check if ASN already exists
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
// 1. Forward to Paperless
const paperlessTaskId = await this.paperlessService.uploadDocument(file.path, {
title: `Beleg ${dto.interneBelegnummer}`,
});
// 2. Create local Task
const task = this.taskRepo.create({
TaskId: paperlessTaskId.replace(/"/g, ''),
InterneBelegnummer: dto.interneBelegnummer,
DocumentType: dto.dokumentType,
Eingangsdatum: dto.Eingangsdatum ? new Date(dto.Eingangsdatum) : null,
Tags: dto.tag ? String(dto.tag) : null,
BetriebID: dto.betriebId,
Lieferant: dto.lieferant,
EinkaufID: dto.einkaufId,
externeBelegnummer: dto.externeBelegnummer,
Belegdatum: dto.belegdatum ? new Date(dto.belegdatum) : null,
TaskReferenceID: dto.parentId || '',
BarcodeJson: dto.barcodeJson || '',
DuplikatZU: dto.duplikatZu,
Fertig: 0,
});
await this.taskRepo.save(task);
this.logger.log(`Externer Upload erfolgreich: ${task.TaskId} für Beleg ${dto.interneBelegnummer}`);
return task.TaskId;
} catch (err) {
this.logger.error(`Fehler beim externen Upload für Beleg ${dto.interneBelegnummer}: ${err.message}`, err.stack);
throw new HttpException(
`Fehler beim Verarbeiten des Dokumenten-Uploads: ${err.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}