Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
bjoernpoettker committed 2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 files changed
+67739

No files matched your search

@@ -0,0 +1,102 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { SettingsController } from './settings.controller';
import { DocumentType } from '../database/entities/document-type.entity';
import { Postprocessing } from '../database/entities/postprocessing.entity';
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { UserClient } from '../database/entities/user-client.entity';
import { Client } from '../database/entities/client.entity';
import { Setting } from '../database/entities/setting.entity';
const makeRepo = (data: any[] = []) => ({
find: jest.fn().mockResolvedValue(data),
findOneByOrFail: jest.fn().mockResolvedValue(data[0] ?? {}),
create: jest.fn().mockImplementation((d) => d),
save: jest.fn().mockImplementation((d) => Promise.resolve({ Id: 99, ...d })),
update: jest.fn().mockResolvedValue({ affected: 1 }),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
});
describe('SettingsController', () => {
let controller: SettingsController;
let docTypeRepo: ReturnType<typeof makeRepo>;
let ppRepo: ReturnType<typeof makeRepo>;
let ppActionRepo: ReturnType<typeof makeRepo>;
let userClientRepo: ReturnType<typeof makeRepo>;
beforeEach(async () => {
docTypeRepo = makeRepo([{ Id: 1, DocumentTypeId: 10, TitelTemplate: 'T' }]);
ppRepo = makeRepo([{ Id: 1, Name: 'Rule1', Order: 1, IsActive: true }]);
ppActionRepo = makeRepo([]);
userClientRepo = makeRepo([{ Id: 1, UserId: 'u1', ClientId: 2 }]);
const module: TestingModule = await Test.createTestingModule({
controllers: [SettingsController],
providers: [
{ provide: getRepositoryToken(DocumentType), useValue: docTypeRepo },
{ provide: getRepositoryToken(Postprocessing), useValue: ppRepo },
{ provide: getRepositoryToken(PostprocessingAction), useValue: ppActionRepo },
{ provide: getRepositoryToken(UserClient), useValue: userClientRepo },
{ provide: getRepositoryToken(Client), useValue: makeRepo() },
{ provide: getRepositoryToken(Setting), useValue: makeRepo([{ ID: 1, Typ: 1, Wert: 'v' }]) },
],
}).compile();
controller = module.get<SettingsController>(SettingsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
// Document Types
it('getDocumentTypes returns list', async () => {
const result = await controller.getDocumentTypes();
expect(result).toHaveLength(1);
expect(docTypeRepo.find).toHaveBeenCalledWith({ order: { Id: 'ASC' } });
});
it('updateDocumentType calls update + findOneByOrFail', async () => {
await controller.updateDocumentType('1', { TitelTemplate: 'New' });
expect(docTypeRepo.update).toHaveBeenCalledWith(1, { TitelTemplate: 'New' });
expect(docTypeRepo.findOneByOrFail).toHaveBeenCalledWith({ Id: 1 });
});
// Postprocessing
it('getPostprocessingRules returns ordered list', async () => {
const result = await controller.getPostprocessingRules();
expect(result).toHaveLength(1);
expect(ppRepo.find).toHaveBeenCalledWith({ order: { Order: 'ASC' } });
});
it('createPostprocessingRule creates and saves', async () => {
const result = await controller.createPostprocessingRule({ Name: 'New' } as any);
expect(ppRepo.create).toHaveBeenCalledWith({ Name: 'New' });
expect(ppRepo.save).toHaveBeenCalled();
expect(result).toHaveProperty('Id', 99);
});
it('deletePostprocessingRule deletes', async () => {
const result = await controller.deletePostprocessingRule('5');
expect(ppRepo.delete).toHaveBeenCalledWith(5);
expect(result).toEqual({ deleted: true });
});
// User Clients
it('getUserClients returns list', async () => {
const result = await controller.getUserClients();
expect(result).toHaveLength(1);
});
it('createUserClient creates', async () => {
const result = await controller.createUserClient({ UserId: 'u2', ClientId: 3 } as any);
expect(userClientRepo.create).toHaveBeenCalled();
expect(result).toHaveProperty('Id', 99);
});
it('deleteUserClient deletes', async () => {
const result = await controller.deleteUserClient('1');
expect(userClientRepo.delete).toHaveBeenCalledWith(1);
expect(result).toEqual({ deleted: true });
});
});
@@ -0,0 +1,391 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Query, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentType } from '../database/entities/document-type.entity';
import { DocumentField } from '../database/entities/document-field.entity';
import { Postprocessing } from '../database/entities/postprocessing.entity';
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
import { ExportTarget } from '../database/entities/export-target.entity';
import { UserClient } from '../database/entities/user-client.entity';
import { PaperlessService } from '../paperless/paperless.service';
import { Client } from '../database/entities/client.entity';
import { Setting } from '../database/entities/setting.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
import { InboxPostprocessingAction, type InboxActionType } from '../database/entities/inbox-postprocessing-action.entity';
import { ExportService } from '../postprocessing/export.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@Controller('api/settings')
@RequirePermissions(Permission.MANAGE_SETTINGS)
export class SettingsController {
private readonly logger = new Logger(SettingsController.name);
constructor(
@InjectRepository(DocumentType) private readonly docTypeRepo: Repository<DocumentType>,
@InjectRepository(Postprocessing) private readonly ppRepo: Repository<Postprocessing>,
@InjectRepository(PostprocessingAction) private readonly ppActionRepo: Repository<PostprocessingAction>,
@InjectRepository(PostprocessingLog) private readonly ppLogRepo: Repository<PostprocessingLog>,
@InjectRepository(ExportTarget) private readonly exportTargetRepo: Repository<ExportTarget>,
@InjectRepository(UserClient) private readonly userClientRepo: Repository<UserClient>,
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(Setting) private readonly settingRepo: Repository<Setting>,
@InjectRepository(DocumentField) private readonly docFieldRepo: Repository<DocumentField>,
@InjectRepository(CorrespondentSetting) private readonly corrSettingRepo: Repository<CorrespondentSetting>,
@InjectRepository(InboxPostprocessingAction) private readonly inboxActionRepo: Repository<InboxPostprocessingAction>,
private readonly paperlessService: PaperlessService,
private readonly exportService: ExportService,
) {}
// === Dokumenttypen ===
@Get('document-types')
async getDocumentTypes() {
try {
const paperlessTypes = await this.paperlessService.getDocumentTypes();
const existing = await this.docTypeRepo.find();
const existingIds = new Set(existing.map(d => d.DocumentTypeId));
for (const pt of paperlessTypes) {
if (!existingIds.has(pt.id)) {
const newDoc = this.docTypeRepo.create({
DocumentTypeId: pt.id,
TitelTemplate: '',
TagNotReady: null,
TagReady: null,
});
await this.docTypeRepo.save(newDoc);
}
}
} catch (err) {
this.logger.error('Fehler beim Synchronisieren der Dokumenttypen von Paperless', err);
}
return this.docTypeRepo.find({ order: { Id: 'ASC' } });
}
@Put('document-types/:id')
async updateDocumentType(@Param('id') id: string, @Body() body: Partial<DocumentType>) {
await this.docTypeRepo.update(parseInt(id, 10), body);
return this.docTypeRepo.findOneByOrFail({ Id: parseInt(id, 10) });
}
// === Document Fields ===
@Get('document-types/:id/fields')
async getDocumentFields(@Param('id') id: string) {
return this.docFieldRepo.find({
where: { DocumentType: parseInt(id, 10) },
order: { TypeIndex: 'ASC', Id: 'ASC' },
});
}
@Post('document-types/:id/fields')
async createDocumentField(@Param('id') id: string, @Body() body: Partial<DocumentField>) {
const field = this.docFieldRepo.create({ ...body, DocumentType: parseInt(id, 10) });
return this.docFieldRepo.save(field);
}
@Put('document-fields/:fieldId')
async updateDocumentField(@Param('fieldId') fieldId: string, @Body() body: Partial<DocumentField>) {
await this.docFieldRepo.update(parseInt(fieldId, 10), body);
return this.docFieldRepo.findOneByOrFail({ Id: parseInt(fieldId, 10) });
}
@Delete('document-fields/:fieldId')
async deleteDocumentField(@Param('fieldId') fieldId: string) {
await this.docFieldRepo.delete(parseInt(fieldId, 10));
return { deleted: true };
}
// === Postprocessing ===
@Get('postprocessing')
async getPostprocessingRules() {
return this.ppRepo.find({ order: { Order: 'ASC' } });
}
@Post('postprocessing')
async createPostprocessingRule(@Body() body: Partial<Postprocessing>) {
const rule = this.ppRepo.create(body);
return this.ppRepo.save(rule);
}
@Put('postprocessing/:id')
async updatePostprocessingRule(@Param('id') id: string, @Body() body: Partial<Postprocessing>) {
await this.ppRepo.update(parseInt(id, 10), body);
return this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) });
}
@Delete('postprocessing/:id')
async deletePostprocessingRule(@Param('id') id: string) {
await this.ppRepo.delete(parseInt(id, 10));
return { deleted: true };
}
@Post('postprocessing/:id/duplicate')
async duplicatePostprocessingRule(@Param('id') id: string) {
const original = await this.ppRepo.findOneByOrFail({ Id: parseInt(id, 10) });
const actions = await this.ppActionRepo.find({ where: { PostprocessingId: original.Id } });
const { Id: _, ...ruleData } = original;
const clone = this.ppRepo.create({ ...ruleData, Name: `${original.Name} (Kopie)` });
const saved = await this.ppRepo.save(clone);
for (const action of actions) {
const { Id: __, ...actionData } = action;
const clonedAction = this.ppActionRepo.create({ ...actionData, PostprocessingId: saved.Id });
await this.ppActionRepo.save(clonedAction);
}
return saved;
}
// === Postprocessing Actions ===
@Get('postprocessing/:id/actions')
async getActions(@Param('id') id: string) {
return this.ppActionRepo.find({
where: { PostprocessingId: parseInt(id, 10) },
order: { Order: 'ASC' },
});
}
@Post('postprocessing/:id/actions')
async createAction(@Param('id') id: string, @Body() body: Partial<PostprocessingAction>) {
const action = this.ppActionRepo.create({ ...body, PostprocessingId: parseInt(id, 10) });
return this.ppActionRepo.save(action);
}
@Put('postprocessing-actions/:actionId')
async updateAction(@Param('actionId') actionId: string, @Body() body: Partial<PostprocessingAction>) {
await this.ppActionRepo.update(parseInt(actionId, 10), body);
return this.ppActionRepo.findOneByOrFail({ Id: parseInt(actionId, 10) });
}
@Delete('postprocessing-actions/:actionId')
async deleteAction(@Param('actionId') actionId: string) {
await this.ppActionRepo.delete(parseInt(actionId, 10));
return { deleted: true };
}
// === Inbox-Aktionen pro Barcode-Vorlage ===
@Get('barcode-templates/:templateId/inbox-actions')
async listInboxActionsForTemplate(@Param('templateId') templateId: string) {
return this.inboxActionRepo.find({
where: { BarcodeTemplateId: parseInt(templateId, 10) },
order: { Order: 'ASC', Id: 'ASC' },
});
}
@Post('barcode-templates/:templateId/inbox-actions')
async createInboxActionForTemplate(
@Param('templateId') templateId: string,
@Body() body: Partial<InboxPostprocessingAction>,
) {
if (!body.ActionType) {
throw new Error('ActionType ist erforderlich');
}
const entity = this.inboxActionRepo.create({
ActionType: body.ActionType as InboxActionType,
Content: body.Content ?? {},
Order: body.Order ?? 0,
IsActive: body.IsActive ?? true,
BarcodeTemplateId: parseInt(templateId, 10),
});
return this.inboxActionRepo.save(entity);
}
// === Inbox-Postprozessor (global, deprecated) ===
@Get('inbox-actions')
async listInboxActions() {
return this.inboxActionRepo.find({ order: { Order: 'ASC', Id: 'ASC' } });
}
@Post('inbox-actions')
async createInboxAction(@Body() body: Partial<InboxPostprocessingAction>) {
if (!body.ActionType) {
throw new Error('ActionType ist erforderlich');
}
const entity = this.inboxActionRepo.create({
ActionType: body.ActionType as InboxActionType,
Content: body.Content ?? {},
Order: body.Order ?? 0,
IsActive: body.IsActive ?? true,
});
return this.inboxActionRepo.save(entity);
}
@Put('inbox-actions/:id')
async updateInboxAction(@Param('id') id: string, @Body() body: Partial<InboxPostprocessingAction>) {
const numId = parseInt(id, 10);
const existing = await this.inboxActionRepo.findOneByOrFail({ Id: numId });
if (body.ActionType !== undefined) existing.ActionType = body.ActionType as InboxActionType;
if (body.Content !== undefined) existing.Content = body.Content;
if (body.Order !== undefined) existing.Order = body.Order;
if (body.IsActive !== undefined) existing.IsActive = body.IsActive;
return this.inboxActionRepo.save(existing);
}
@Delete('inbox-actions/:id')
async deleteInboxAction(@Param('id') id: string) {
await this.inboxActionRepo.delete(parseInt(id, 10));
return { deleted: true };
}
// === Export-Ziele ===
@Get('export-targets')
async getExportTargets() {
return this.exportTargetRepo.find({ order: { Id: 'ASC' } });
}
@Post('export-targets')
async createExportTarget(@Body() body: Partial<ExportTarget>) {
const target = this.exportTargetRepo.create(body);
return this.exportTargetRepo.save(target);
}
@Put('export-targets/:id')
async updateExportTarget(@Param('id') id: string, @Body() body: Partial<ExportTarget>) {
await this.exportTargetRepo.update(parseInt(id, 10), body);
return this.exportTargetRepo.findOneByOrFail({ Id: parseInt(id, 10) });
}
@Delete('export-targets/:id')
async deleteExportTarget(@Param('id') id: string) {
await this.exportTargetRepo.delete(parseInt(id, 10));
return { deleted: true };
}
@Post('export-targets/:id/test')
async testExportTarget(@Param('id') id: string) {
return this.exportService.testConnection(parseInt(id, 10));
}
// === Postprocessing Logs ===
@Get('postprocessing-logs')
async getPostprocessingLogs(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
const take = parseInt(limit || '50', 10);
const skip = parseInt(offset || '0', 10);
const [data, total] = await this.ppLogRepo.findAndCount({
order: { CreatedAt: 'DESC' },
take,
skip,
});
return { data, total };
}
// === Benutzer-Betrieb Zuordnung ===
@Get('user-clients')
async getUserClients() {
return this.userClientRepo.find();
}
@Post('user-clients')
async createUserClient(@Body() body: Partial<UserClient>) {
const uc = this.userClientRepo.create(body);
return this.userClientRepo.save(uc);
}
@Delete('user-clients/:id')
async deleteUserClient(@Param('id') id: string) {
await this.userClientRepo.delete(parseInt(id, 10));
return { deleted: true };
}
// === Allgemeine Einstellungen ===
@Get('general')
async getSettings() {
return this.settingRepo.find();
}
@Put('general/:id')
async updateSetting(@Param('id') id: string, @Body() body: { value: string }) {
await this.settingRepo.update(parseInt(id, 10), { Wert: body.value });
return this.settingRepo.findOneByOrFail({ ID: parseInt(id, 10) });
}
// === Korrespondenten ===
@Get('correspondents')
async getCorrespondents(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('search') search?: string,
) {
try {
const params: any = {
page: page || '1',
page_size: pageSize || '50',
};
if (search) {
params.name__icontains = search;
}
const response = await this.paperlessService.getCorrespondents(params);
const paperlessCorrs = response.results;
const settings = await this.corrSettingRepo.find();
const settingsMap = new Map(settings.map(s => [s.CorrespondentId, s]));
for (const pc of paperlessCorrs) {
if (!settingsMap.has(pc.id)) {
const newSetting = this.corrSettingRepo.create({
CorrespondentId: pc.id,
AgrarmonitorId: null,
});
await this.corrSettingRepo.save(newSetting);
}
}
// Re-fetch merged
const finalSettings = await this.corrSettingRepo.find();
const merged = paperlessCorrs.map((pc: any) => ({
...pc,
agrarmonitorId: finalSettings.find(s => s.CorrespondentId === pc.id)?.AgrarmonitorId || null,
}));
return {
data: merged,
total: response.count,
};
} catch (err) {
this.logger.error('Fehler beim Synchronisieren der Korrespondenten von Paperless', err);
return { data: [], total: 0 };
}
}
@Post('correspondents')
async createCorrespondent(@Body() body: { name: string }) {
const data = {
name: body.name,
match: '',
matching_algorithm: 0,
is_insensitive: true,
owner: null, // User said 0, but null is standard for public in Paperless. I'll use null.
};
const created = await this.paperlessService.addCorrespondent(data);
// Also ensure setting entry exists
const newSetting = this.corrSettingRepo.create({
CorrespondentId: created.id,
AgrarmonitorId: null,
});
await this.corrSettingRepo.save(newSetting);
return { ...created, agrarmonitorId: null };
}
@Put('correspondents/:id')
async updateCorrespondentSetting(@Param('id') id: string, @Body() body: { agrarmonitorId: number | null }) {
const corrId = parseInt(id, 10);
let setting = await this.corrSettingRepo.findOneBy({ CorrespondentId: corrId });
if (!setting) {
setting = this.corrSettingRepo.create({ CorrespondentId: corrId, AgrarmonitorId: body.agrarmonitorId });
} else {
setting.AgrarmonitorId = body.agrarmonitorId;
}
return this.corrSettingRepo.save(setting);
}
}
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DocumentType } from '../database/entities/document-type.entity';
import { Postprocessing } from '../database/entities/postprocessing.entity';
import { PostprocessingAction } from '../database/entities/postprocessing-action.entity';
import { PostprocessingLog } from '../database/entities/postprocessing-log.entity';
import { ExportTarget } from '../database/entities/export-target.entity';
import { UserClient } from '../database/entities/user-client.entity';
import { Client } from '../database/entities/client.entity';
import { Setting } from '../database/entities/setting.entity';
import { DocumentField } from '../database/entities/document-field.entity';
import { CorrespondentSetting } from '../database/entities/correspondent-setting.entity';
import { InboxPostprocessingAction } from '../database/entities/inbox-postprocessing-action.entity';
import { SettingsController } from './settings.controller';
import { PaperlessModule } from '../paperless/paperless.module';
import { PostprocessingModule } from '../postprocessing/postprocessing.module';
@Module({
imports: [
TypeOrmModule.forFeature([
DocumentType,
Postprocessing,
PostprocessingAction,
PostprocessingLog,
ExportTarget,
UserClient,
Client,
Setting,
DocumentField,
CorrespondentSetting,
InboxPostprocessingAction,
]),
PaperlessModule,
PostprocessingModule,
],
controllers: [SettingsController],
})
export class SettingsModule {}