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,29 @@
import { Controller, Get, Request, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { Client } from '../database/entities/client.entity';
import { UserClient } from '../database/entities/user-client.entity';
@Controller('api/clients')
export class ClientsController {
private readonly logger = new Logger(ClientsController.name);
constructor(
@InjectRepository(Client) private readonly clientRepo: Repository<Client>,
@InjectRepository(UserClient) private readonly userClientRepo: Repository<UserClient>,
) {}
@Get()
async getMyClients(@Request() req: any) {
const userId = req.user.userId;
const mappings = await this.userClientRepo.find({ where: { UserId: userId } });
const clientIds = mappings.map((m) => m.ClientId);
if (clientIds.length === 0) {
// Fallback to match old C# behavior where it returned all clients
// or if admin users shouldn't be restricted initially.
return this.clientRepo.find();
}
return this.clientRepo.find({ where: { Id: In(clientIds) } });
}
}
@@ -0,0 +1,139 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { randomUUID } from 'crypto';
import * as path from 'path';
import * as fs from 'fs/promises';
import {
InboxDocument,
type InboxSource,
type StoredQrCode,
} from '../database/entities/inbox-document.entity';
import { PageCacheService } from '../barcode/page-cache.service';
interface LegacyScanRow {
QrCodes: string | StoredQrCode[];
}
@Injectable()
export class InboxMigrationService implements OnApplicationBootstrap {
private readonly logger = new Logger(InboxMigrationService.name);
private readonly legacyRoot: string;
constructor(
private readonly configService: ConfigService,
private readonly pageCache: PageCacheService,
private readonly dataSource: DataSource,
@InjectRepository(InboxDocument)
private readonly documentRepo: Repository<InboxDocument>,
) {
this.legacyRoot = this.configService.get<string>('SCANS_DATA_DIR', '/mnt/data/scans');
}
async onApplicationBootstrap(): Promise<void> {
let subdirs: string[];
try {
const entries = await fs.readdir(this.legacyRoot, { withFileTypes: true });
subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch (err: any) {
if (err.code !== 'ENOENT') {
this.logger.warn(`Migration: ${this.legacyRoot} nicht lesbar: ${err.message}`);
}
return;
}
let migrated = 0;
for (const subdir of subdirs) {
const dir = path.join(this.legacyRoot, subdir);
let files: string[];
try {
files = await fs.readdir(dir);
} catch (err: any) {
this.logger.warn(`Migration: ${dir} nicht lesbar: ${err.message}`);
continue;
}
for (const name of files) {
if (path.extname(name).toLowerCase() !== '.pdf') continue;
const src = path.join(dir, name);
try {
await this.migrateFile(src, subdir, name);
migrated += 1;
} catch (err: any) {
this.logger.error(`Migration fehlgeschlagen (${src}): ${err.message}`);
}
}
await fs.rmdir(dir).catch(() => undefined);
}
if (migrated > 0) {
this.logger.log(`Migration: ${migrated} Datei(en) übernommen`);
} else {
this.logger.log('Migration: keine Altdaten gefunden');
}
}
private async migrateFile(src: string, subdir: string, name: string): Promise<void> {
const id = randomUUID();
const source: InboxSource = subdir === 'all' ? 'all' : 'user';
const owner = source === 'all' ? null : subdir;
const targetDir = this.pageCache.documentDir(id);
const targetPdf = this.pageCache.documentPdfPath(id);
await fs.mkdir(targetDir, { recursive: true });
await this.move(src, targetPdf);
const qrCodes = await this.loadLegacyQrCodes(src);
const doc = this.documentRepo.create({
Id: id,
OriginalName: name,
Source: source,
OwnerUsername: owner,
PageCount: 0,
QrCodes: qrCodes,
});
await this.documentRepo.save(doc);
}
private async move(src: string, dest: string): Promise<void> {
try {
await fs.rename(src, dest);
return;
} catch (err: any) {
if (err.code !== 'EXDEV') throw err;
}
await fs.copyFile(src, dest);
try {
await fs.unlink(src);
} catch (err) {
await fs.unlink(dest).catch(() => undefined);
throw err;
}
}
private async loadLegacyQrCodes(oldFilePath: string): Promise<StoredQrCode[]> {
try {
const rows = await this.dataSource.query<LegacyScanRow[]>(
'SELECT QrCodes FROM barcode_scans WHERE FilePath = ? LIMIT 1',
[oldFilePath],
);
if (!rows || rows.length === 0) return [];
const raw = rows[0].QrCodes;
if (typeof raw === 'string') {
try {
return JSON.parse(raw) as StoredQrCode[];
} catch {
return [];
}
}
return Array.isArray(raw) ? raw : [];
} catch {
// Tabelle fehlt oder anderes DB-Problem: Backfill scannt später.
return [];
}
}
}
@@ -0,0 +1,145 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Post,
Put,
Request,
Res,
StreamableFile,
} from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream } from 'fs';
import { InboxService } from './inbox.service';
import { InboxPostprocessorService } from '../inbox-postprocessor/inbox-postprocessor.service';
import { BarcodeScannerService } from '../barcode/barcode-scanner.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@Controller('api/inbox')
@RequirePermissions(Permission.VIEW_SCANNER)
export class InboxController {
constructor(
private readonly inboxService: InboxService,
private readonly postprocessor: InboxPostprocessorService,
private readonly barcodeScanner: BarcodeScannerService,
) {}
@Get()
async list(@Request() req: any) {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
return this.inboxService.listFiles(preferredUsername);
}
@Post('rescan')
async rescan() {
return this.barcodeScanner.rescanAll();
}
@Get(':id/preview')
async preview(
@Param('id') id: string,
@Request() req: any,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
const { doc, pdfPath } = await this.inboxService.resolveDocument(id, preferredUsername);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${doc.OriginalName}"`);
return new StreamableFile(createReadStream(pdfPath));
}
@Get(':id/pages/:page/thumbnail')
async pageThumbnail(
@Param('id') id: string,
@Param('page', ParseIntPipe) page: number,
@Request() req: any,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
const filePath = await this.inboxService.resolvePageImage(id, page, 'thumbnail', preferredUsername);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'private, max-age=3600');
return new StreamableFile(createReadStream(filePath));
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string, @Request() req: any): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
await this.inboxService.deleteDocument(id, preferredUsername);
}
@Delete(':id/pages/:page')
@HttpCode(204)
async removePage(
@Param('id') id: string,
@Param('page', ParseIntPipe) page: number,
@Request() req: any,
): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
await this.inboxService.deletePage(id, page, preferredUsername);
}
@Post(':id/reset-edits')
@HttpCode(204)
async resetEdits(@Param('id') id: string, @Request() req: any): Promise<void> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
await this.inboxService.resetEdits(id, preferredUsername);
}
@Post(':id/postprocess')
async postprocess(
@Param('id') id: string,
@Request() req: any,
@Body() body: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean },
) {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
const { results, totalSections } = await this.postprocessor.runForDocument(
id,
preferredUsername,
body?.sectionOffset ?? 0,
body?.processOnlyOne ?? false,
body?.replaceDuplicate ?? false,
);
return { results, totalSections };
}
@Put(':id/pages/:page/rotation')
@HttpCode(204)
async setPageRotation(
@Param('id') id: string,
@Param('page', ParseIntPipe) page: number,
@Body() body: { rotation?: number },
@Request() req: any,
): Promise<void> {
const rotation = Number(body?.rotation);
if (!Number.isFinite(rotation)) {
throw new BadRequestException('rotation muss eine Zahl sein');
}
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
await this.inboxService.setPageRotation(id, page, rotation, preferredUsername);
}
@Get(':id/pages/:page/preview')
async pagePreview(
@Param('id') id: string,
@Param('page', ParseIntPipe) page: number,
@Request() req: any,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const preferredUsername: string | null = req.user?.preferredUsername ?? null;
const filePath = await this.inboxService.resolvePageImage(id, page, 'preview', preferredUsername);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'private, max-age=3600');
return new StreamableFile(createReadStream(filePath));
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Client } from '../database/entities/client.entity';
import { UserClient } from '../database/entities/user-client.entity';
import { InboxDocument } from '../database/entities/inbox-document.entity';
import { InboxController } from './inbox.controller';
import { ClientsController } from './clients.controller';
import { InboxService } from './inbox.service';
import { InboxMigrationService } from './inbox-migration.service';
import { BarcodeModule } from '../barcode/barcode.module';
import { InboxPostprocessorModule } from '../inbox-postprocessor/inbox-postprocessor.module';
@Module({
imports: [
TypeOrmModule.forFeature([Client, UserClient, InboxDocument]),
BarcodeModule,
InboxPostprocessorModule,
],
controllers: [InboxController, ClientsController],
providers: [InboxService, InboxMigrationService],
exports: [InboxService],
})
export class InboxModule {}
@@ -0,0 +1,193 @@
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs/promises';
import { BarcodeScannerService, type MatchedBarcode } from '../barcode/barcode-scanner.service';
import { PageCacheService } from '../barcode/page-cache.service';
import {
InboxDocument,
type InboxSource,
} from '../database/entities/inbox-document.entity';
export interface InboxFile {
id: string;
name: string;
source: InboxSource;
pageCount: number;
deletedPages: number[];
rotations: Record<string, number>;
barcodes: MatchedBarcode[];
createdAt: string;
}
export interface ResolvedDocument {
doc: InboxDocument;
pdfPath: string;
}
@Injectable()
export class InboxService {
private readonly logger = new Logger(InboxService.name);
constructor(
private readonly barcodeScanner: BarcodeScannerService,
private readonly pageCache: PageCacheService,
@InjectRepository(InboxDocument)
private readonly documentRepo: Repository<InboxDocument>,
) {}
async listFiles(preferredUsername: string | null): Promise<InboxFile[]> {
const where = preferredUsername
? [{ Source: 'all' as InboxSource }, { Source: 'user' as InboxSource, OwnerUsername: preferredUsername }]
: [{ Source: 'all' as InboxSource }];
const docs = await this.documentRepo.find({
where,
order: { CreatedAt: 'DESC' },
});
const files: InboxFile[] = [];
for (const doc of docs) {
files.push({
id: doc.Id,
name: doc.OriginalName,
source: doc.Source,
pageCount: doc.PageCount,
deletedPages: [...(doc.DeletedPages ?? [])].sort((a, b) => a - b),
rotations: { ...(doc.Rotations ?? {}) },
barcodes: await this.barcodeScanner.getMatched(doc),
createdAt: doc.CreatedAt.toISOString(),
});
}
return files;
}
async resolveDocument(id: string, preferredUsername: string | null): Promise<ResolvedDocument> {
const doc = await this.documentRepo.findOne({ where: { Id: id } });
if (!doc) throw new NotFoundException('Dokument nicht gefunden');
if (doc.Source === 'user' && doc.OwnerUsername !== preferredUsername) {
throw new NotFoundException('Dokument nicht gefunden');
}
const pdfPath = this.pageCache.documentPdfPath(doc.Id);
try {
const stat = await fs.stat(pdfPath);
if (!stat.isFile()) throw new Error('not a file');
} catch (err: any) {
this.logger.warn(`Datei fehlt trotz DB-Eintrag (${doc.Id}): ${err.message}`);
throw new NotFoundException('Dokument nicht gefunden');
}
return { doc, pdfPath };
}
/**
* Markiert eine Seite virtuell zum Löschen. Die PDF und der Page-Cache
* bleiben unverändert; die eigentliche Anwendung passiert später bei
* der Weiterverarbeitung.
*/
async deletePage(
id: string,
page: number,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
throw new NotFoundException('Seite nicht gefunden');
}
const deleted = new Set<number>(doc.DeletedPages ?? []);
if (deleted.has(page)) return; // schon markiert
const remaining = doc.PageCount - deleted.size;
if (remaining <= 1) {
throw new ConflictException('Mindestens eine Seite muss übrig bleiben');
}
deleted.add(page);
doc.DeletedPages = Array.from(deleted).sort((a, b) => a - b);
await this.documentRepo.save(doc);
}
/**
* Setzt eine Seitenrotation virtuell. Wert wird auf 0/90/180/270
* normalisiert; 0 entfernt den Eintrag.
*/
async setPageRotation(
id: string,
page: number,
rotation: number,
preferredUsername: string | null,
): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
throw new NotFoundException('Seite nicht gefunden');
}
const normalized = ((Math.round(rotation / 90) * 90) % 360 + 360) % 360;
const next: Record<string, number> = { ...(doc.Rotations ?? {}) };
if (normalized === 0) {
delete next[String(page)];
} else {
next[String(page)] = normalized;
}
doc.Rotations = next;
await this.documentRepo.save(doc);
}
/**
* Setzt alle markierten Bearbeitungen (DeletedPages, Rotations) zurück.
*/
async resetEdits(id: string, preferredUsername: string | null): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
let changed = false;
if (doc.DeletedPages && doc.DeletedPages.length > 0) {
doc.DeletedPages = [];
changed = true;
}
if (doc.Rotations && Object.keys(doc.Rotations).length > 0) {
doc.Rotations = {};
changed = true;
}
if (changed) await this.documentRepo.save(doc);
}
async deleteDocument(id: string, preferredUsername: string | null): Promise<void> {
const { doc } = await this.resolveDocument(id, preferredUsername);
const dir = this.pageCache.documentDir(doc.Id);
await this.documentRepo.delete(doc.Id);
try {
await fs.rm(dir, { recursive: true, force: true });
} catch (err: any) {
this.logger.warn(`Dokument-Ordner konnte nicht gelöscht werden (${dir}): ${err.message}`);
}
}
async resolvePageImage(
id: string,
page: number,
variant: 'preview' | 'thumbnail',
preferredUsername: string | null,
): Promise<string> {
const { doc } = await this.resolveDocument(id, preferredUsername);
if (!Number.isInteger(page) || page < 1 || page > doc.PageCount) {
throw new NotFoundException('Seite nicht gefunden');
}
const filePath =
variant === 'preview'
? this.pageCache.previewPath(doc.Id, page)
: this.pageCache.thumbnailPath(doc.Id, page);
try {
await fs.access(filePath);
} catch {
throw new NotFoundException('Seite nicht gefunden');
}
return filePath;
}
}