Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user