diff --git a/.env.example b/.env.example index e62dd5b..3bdae21 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ # Produktion: VITE_API_URL leer lassen (nginx Reverse-Proxy leitet /api weiter) # ============================================================================= +# --- Umgebung --- +# Produktion: NODE_ENV=production -> KEIN TypeORM-synchronize (Schema via Migrationen), +# Migrationen werden beim Start automatisch ausgeführt (migrationsRun). +# Aktiviert zudem CORS-Schutz (siehe CORS_ORIGIN weiter unten). +# Entwicklung: NODE_ENV leer lassen -> synchronize ON (Schema folgt den Entities). +NODE_ENV=production + # --- Ports --- BACKEND_PORT=7601 FRONTEND_PORT=7600 diff --git a/.gitignore b/.gitignore index 621fa9d..ef6731f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ dist/ # Docker docker-compose.override.yml + +# Secrets +.gitea_token diff --git a/CLAUDE.md b/CLAUDE.md index 2f95805..5b7b70d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,12 @@ Paperless Manager is a document automation platform that extends [Paperless-NGX] UI labels and comments are in **German**. +## Sprache + +Alle Antworten an den Nutzer, Erklärungen, Commit-Messages, Code-Kommentare und UI-Texte +sollen — wo möglich — auf **Deutsch** verfasst werden. Technische Bezeichner (Variablen-, +Funktions- und Dateinamen) bleiben unverändert in der bestehenden Konvention. + ## Structure ``` @@ -78,11 +84,37 @@ Use `@Public()` to bypass auth guards entirely. ### Database -- TypeORM with MySQL 8+, UTF8MB4, `synchronize: true` (schema auto-migrates) +- TypeORM with MySQL/MariaDB, UTF8MB4 +- Connection config lives in `src/database/data-source.ts` (single source of truth, + shared by the NestJS runtime and the TypeORM CLI). `database.module.ts` consumes it. +- **Schema strategy depends on `NODE_ENV`:** + - Dev (`NODE_ENV` unset): `synchronize: true` — schema auto-migrates from entities. + - Production (`NODE_ENV=production`): `synchronize: false` + `migrationsRun: true` — + pending migrations in `src/database/migrations/` are applied automatically on boot. + **Never run `synchronize` against the production DB** — on MariaDB it issues + destructive `ADD`/`DROP COLUMN` churn every boot (see caveat below). - 23 entities in `src/database/entities/` - JSON columns use transformers to normalize empty arrays/objects to `null` - Key entities: `InboxDocument`, `Task`, `Email`, `Attachment`, `Postprocessing`, `BarcodeTemplate`, `LabelPrintJob`, `ApiKey`, `Setting` +#### Migrations workflow + +```bash +npm run migration:generate -- src/database/migrations/ # diff entities → DB +npm run migration:run # apply pending +npm run migration:revert # roll back last +``` + +The existing production schema is the implicit baseline (no baseline migration); only +future changes get migration files. + +**Caveat — MariaDB reports `json` as `longtext`:** every `@Column({ type: 'json' })` +is stored as `longtext ... CHECK (json_valid(...))`, so `migration:generate` always +emits spurious no-op `CHANGE` statements (json↔longtext, nullable/default re-declares). +**Hand-trim generated migrations** down to the real change before committing. (This same +false diff is exactly why `synchronize` must stay off in production — left unchecked it +accumulates `ALGORITHM=INSTANT` drops until a table trips the 8126-byte row-size limit.) + ### Document Processing Pipeline **Preprocessing** (`preprocessing/document-pipeline.service.ts`): diff --git a/docker-compose.yml b/docker-compose.yml index 12a8d0b..1d37bea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: ports: - "${BACKEND_PORT:-7601}:3100" environment: + - NODE_ENV=production + - CORS_ORIGIN=${CORS_ORIGIN:-} - PORT=3100 - DB_HOST=${DB_HOST:-db} - DB_PORT=${DB_PORT:-3306} @@ -36,6 +38,8 @@ services: - IMAP_USE_SSL=${IMAP_USE_SSL:-true} - IMAP_USERNAME=${IMAP_USERNAME:-} - IMAP_PASSWORD=${IMAP_PASSWORD:-} + - IMAP_IMPORTED_FOLDER=${IMAP_IMPORTED_FOLDER:-importiert} + - IMAP_TRASH_FOLDER=${IMAP_TRASH_FOLDER:-Trash} - BELEGNUMMER_GET_URL=${BELEGNUMMER_GET_URL:-} - BELEGNUMMER_SET_URL=${BELEGNUMMER_SET_URL:-} - AGRARMONITOR_BASE_URL=${AGRARMONITOR_BASE_URL:-https://admin7.agrarmonitor.de} diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 21116f7..004178f 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -24,6 +24,7 @@ "axios": "^1.14.0", "basic-ftp": "^5.2.1", "chokidar": "^4.0.3", + "dotenv": "^17.4.2", "form-data": "^4.0.5", "imapflow": "^1.3.2", "jsqr": "^1.4.0", @@ -3023,6 +3024,18 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "11.1.17", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz", @@ -6291,9 +6304,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" diff --git a/paperless-backend/package.json b/paperless-backend/package.json index 5f4d168..9ec86d9 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -17,7 +17,11 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs -d ./src/database/data-source.ts", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -35,6 +39,7 @@ "axios": "^1.14.0", "basic-ftp": "^5.2.1", "chokidar": "^4.0.3", + "dotenv": "^17.4.2", "form-data": "^4.0.5", "imapflow": "^1.3.2", "jsqr": "^1.4.0", diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 9e0cdab..1702c11 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -56,6 +56,8 @@ export class AgrarmonitorPollingService implements OnModuleInit { await this.upsertSetting('agrarmonitor_tag_hochgeladen', ''); await this.upsertSetting('agrarmonitor_link_field', ''); await this.upsertSetting('agrarmonitor_tag_manuell', ''); + await this.upsertSetting('agrarmonitor_import_wartezeit_minuten', '10'); + await this.upsertSetting('agrarmonitor_notiz_marker', 'Agrarmonitor'); } @Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *') @@ -80,21 +82,36 @@ export class AgrarmonitorPollingService implements OnModuleInit { tagHochgeladen: string; linkField: string; tagManuell: string; + importWartezeitMinuten: string; + notizMarker: string; }> { - const [fertig, verbucht, hochgeladen, linkField, manuell] = - await Promise.all([ - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), - ]); + const [ + fertig, + verbucht, + hochgeladen, + linkField, + manuell, + wartezeit, + marker, + ] = await Promise.all([ + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), + this.settingRepo.findOneBy({ + Tag: 'agrarmonitor_import_wartezeit_minuten', + }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }), + ]); return { tagFertig: fertig?.Wert ?? '4', tagVerbucht: verbucht?.Wert ?? '9', tagHochgeladen: hochgeladen?.Wert ?? '', linkField: linkField?.Wert ?? '', tagManuell: manuell?.Wert ?? '', + importWartezeitMinuten: wartezeit?.Wert ?? '10', + notizMarker: marker?.Wert ?? 'Agrarmonitor', }; } @@ -104,12 +121,16 @@ export class AgrarmonitorPollingService implements OnModuleInit { tagHochgeladen: string, linkField: string, tagManuell: string, + importWartezeitMinuten: string, + notizMarker: string, ): Promise<{ tagFertig: string; tagVerbucht: string; tagHochgeladen: string; linkField: string; tagManuell: string; + importWartezeitMinuten: string; + notizMarker: string; }> { await Promise.all([ this.settingRepo.update( @@ -132,8 +153,24 @@ export class AgrarmonitorPollingService implements OnModuleInit { { Tag: 'agrarmonitor_tag_manuell' }, { Wert: tagManuell }, ), + this.settingRepo.update( + { Tag: 'agrarmonitor_import_wartezeit_minuten' }, + { Wert: importWartezeitMinuten }, + ), + this.settingRepo.update( + { Tag: 'agrarmonitor_notiz_marker' }, + { Wert: notizMarker }, + ), ]); - return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell }; + return { + tagFertig, + tagVerbucht, + tagHochgeladen, + linkField, + tagManuell, + importWartezeitMinuten, + notizMarker, + }; } async runPolling(): Promise { @@ -393,17 +430,26 @@ export class AgrarmonitorPollingService implements OnModuleInit { fertigSetting, linkFieldSetting, manuellSetting, + wartezeitSetting, + markerSetting, ] = await Promise.all([ this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), + this.settingRepo.findOneBy({ + Tag: 'agrarmonitor_import_wartezeit_minuten', + }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }), ]); const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10); const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10); const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10); const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10); + const importWartezeitMinuten = + parseInt(wartezeitSetting?.Wert ?? '10', 10) || 10; + const notizMarker = (markerSetting?.Wert ?? 'Agrarmonitor').trim(); if (isNaN(tagHochgeladenId)) { this.logger.warn( @@ -512,6 +558,40 @@ export class AgrarmonitorPollingService implements OnModuleInit { continue; } + // Karenzzeit: evtl. hat Agrarmonitor die E-Mail noch nicht importiert. + // Ist die Sende-Notiz jünger als die konfigurierte Wartezeit, nicht markieren. + const marker = notizMarker.toLowerCase(); + if (marker) { + let notes: Array<{ note: string; created: string }>; + try { + notes = await this.paperlessService.getNotes(doc.id as number); + } catch (err: unknown) { + // Vorsichtig: bei Notiz-Abruf-Fehler NICHT markieren, nächster Lauf prüft erneut + this.logger.warn( + `${interneBelegnummer}: Notiz-Abruf fehlgeschlagen — nicht markiert: ${err instanceof Error ? err.message : err}`, + ); + result.skipped++; + await this.delay(500); + continue; + } + const newest = notes + .filter((n) => (n.note ?? '').toLowerCase().includes(marker)) + .map((n) => new Date(n.created).getTime()) + .filter((t) => !isNaN(t)) + .reduce((max, t) => Math.max(max, t), 0); + if ( + newest > 0 && + Date.now() - newest < importWartezeitMinuten * 60 * 1000 + ) { + this.logger.log( + `${interneBelegnummer}: Sende-Notiz jünger als ${importWartezeitMinuten} Min — warte auf Import`, + ); + result.skipped++; + await this.delay(500); + continue; + } + } + // Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen if (!isNaN(tagManuellId)) { const currentTags: number[] = (doc.tags as number[]) ?? []; diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts index 5d5cfef..c98f6f8 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor.controller.ts @@ -42,6 +42,8 @@ export class AgrarmonitorController { tagHochgeladen: string; linkField: string; tagManuell: string; + importWartezeitMinuten: string; + notizMarker: string; }, ) { return this.pollingService.updatePollingConfig( @@ -50,6 +52,8 @@ export class AgrarmonitorController { body.tagHochgeladen, body.linkField, body.tagManuell ?? '', + body.importWartezeitMinuten ?? '10', + body.notizMarker ?? 'Agrarmonitor', ); } diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index f28e841..a70dfeb 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module'; import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module'; import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module'; import { FreigabeModule } from './freigabe/freigabe.module'; +import { ZahlungModule } from './zahlung/zahlung.module'; import { DailyDigestModule } from './daily-digest/daily-digest.module'; import * as path from 'path'; @@ -52,6 +53,7 @@ import * as path from 'path'; LabelPrintAgentModule, AgrarmonitorModule, FreigabeModule, + ZahlungModule, DailyDigestModule, ], }) diff --git a/paperless-backend/src/auth/api-key.guard.ts b/paperless-backend/src/auth/api-key.guard.ts index fad19c5..c34531a 100644 --- a/paperless-backend/src/auth/api-key.guard.ts +++ b/paperless-backend/src/auth/api-key.guard.ts @@ -28,12 +28,19 @@ export class ApiKeyGuard implements CanActivate { if (apiKey) source = 'apiKey query param'; } - // Fallback to Authorization: Bearer (used by SSE clients that can't set X-API-Key) + // Fallback to Authorization: Bearer (used by SSE clients that can't set + // X-API-Key). Nur akzeptieren, wenn das Token wie ein API-Key aussieht + // (Präfix "pm_"). Ein (abgelaufenes) JWT als Bearer-Token wird hier ignoriert, + // statt es fälschlich als API-Key zu prüfen – das vermeidet die irreführende + // "Invalid API Key"-Warnung beim normalen JWT-Ablauf. if (!apiKey) { const auth: string | undefined = request.headers['authorization']; if (auth?.startsWith('Bearer ')) { - apiKey = auth.slice(7); - source = 'Authorization: Bearer'; + const token = auth.slice(7); + if (token.startsWith('pm_')) { + apiKey = token; + source = 'Authorization: Bearer'; + } } } diff --git a/paperless-backend/src/auth/permissions.enum.ts b/paperless-backend/src/auth/permissions.enum.ts index 5564e78..ac28159 100644 --- a/paperless-backend/src/auth/permissions.enum.ts +++ b/paperless-backend/src/auth/permissions.enum.ts @@ -6,6 +6,7 @@ export const Permission = { VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', VIEW_FREIGABE: 'VIEW_FREIGABE', + VIEW_ZAHLUNG: 'VIEW_ZAHLUNG', } as const; export type Permission = (typeof Permission)[keyof typeof Permission]; @@ -27,6 +28,7 @@ export function mapGroupsToPermissions( permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.VIEW_FREIGABE); + permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } @@ -36,6 +38,7 @@ export function mapGroupsToPermissions( if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE); + if (groups.includes('PM_Zahlung')) permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } diff --git a/paperless-backend/src/database/data-source.ts b/paperless-backend/src/database/data-source.ts new file mode 100644 index 0000000..952bf6b --- /dev/null +++ b/paperless-backend/src/database/data-source.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata'; +import { join } from 'path'; +import { config as loadEnv } from 'dotenv'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import { + Client, + DocumentType, + DocumentField, + Task, + Postprocessing, + PostprocessingAction, + PostprocessingLog, + ExportTarget, + Setting, + Kontonummer, + Document, + UserClient, + Email, + Attachment, + Content, + ApiKey, + CorrespondentSetting, + BarcodeTemplate, + InboxDocument, + InboxPostprocessingAction, + CorrespondentEmailMapping, + UserSettings, + LabelPrintJob, + WebhookQueueItem, +} from './entities'; + +// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker, +// dotenv ist dort ein No-Op). dotenv überschreibt bereits gesetzte Variablen nicht. +loadEnv(); +loadEnv({ path: join(process.cwd(), '..', '.env') }); + +export const entities = [ + Client, + DocumentType, + DocumentField, + Task, + Postprocessing, + PostprocessingAction, + PostprocessingLog, + ExportTarget, + Setting, + Kontonummer, + Document, + UserClient, + Email, + Attachment, + Content, + ApiKey, + CorrespondentSetting, + BarcodeTemplate, + InboxDocument, + InboxPostprocessingAction, + CorrespondentEmailMapping, + UserSettings, + LabelPrintJob, + WebhookQueueItem, +]; + +const isProduction = process.env.NODE_ENV === 'production'; + +export const dataSourceOptions: DataSourceOptions = { + type: 'mysql', + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 3306), + username: process.env.DB_USERNAME ?? 'root', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_DATABASE ?? 'paperlessadd', + charset: 'utf8mb4', + entities, + migrations: [join(__dirname, 'migrations', '*.{ts,js}')], + // In Produktion: kein synchronize (zerstörerischer ALTER-Churn), Migrationen + // werden beim Start automatisch ausgeführt. In Dev: synchronize wie bisher. + synchronize: !isProduction, + migrationsRun: isProduction, +}; + +// Wird von der TypeORM-CLI verwendet (migration:generate / :run / :revert). +export default new DataSource(dataSourceOptions); diff --git a/paperless-backend/src/database/database.module.ts b/paperless-backend/src/database/database.module.ts index a3d730b..8ca67f7 100644 --- a/paperless-backend/src/database/database.module.ts +++ b/paperless-backend/src/database/database.module.ts @@ -1,75 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { - Client, - DocumentType, - DocumentField, - Task, - Postprocessing, - PostprocessingAction, - PostprocessingLog, - ExportTarget, - Setting, - Kontonummer, - Document, - UserClient, - Email, - Attachment, - Content, - ApiKey, - CorrespondentSetting, - BarcodeTemplate, - InboxDocument, - InboxPostprocessingAction, - CorrespondentEmailMapping, - UserSettings, - LabelPrintJob, -} from './entities'; - -const entities = [ - Client, - DocumentType, - DocumentField, - Task, - Postprocessing, - PostprocessingAction, - PostprocessingLog, - ExportTarget, - Setting, - Kontonummer, - Document, - UserClient, - Email, - Attachment, - Content, - ApiKey, - CorrespondentSetting, - BarcodeTemplate, - InboxDocument, - InboxPostprocessingAction, - CorrespondentEmailMapping, - UserSettings, - LabelPrintJob, -]; +import { dataSourceOptions, entities } from './data-source'; @Module({ imports: [ - TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - type: 'mysql' as const, - host: config.get('DB_HOST', 'localhost'), - port: config.get('DB_PORT', 3306), - username: config.get('DB_USERNAME', 'root'), - password: config.get('DB_PASSWORD', ''), - database: config.get('DB_DATABASE', 'paperlessadd'), - entities, - synchronize: config.get('NODE_ENV') !== 'production', - charset: 'utf8mb4', - }), - }), + // Eine einzige Konfigurationsquelle (data-source.ts), geteilt zwischen + // NestJS-Laufzeit und TypeORM-CLI (Migrationen). + TypeOrmModule.forRoot(dataSourceOptions), TypeOrmModule.forFeature(entities), ], exports: [TypeOrmModule], diff --git a/paperless-backend/src/database/entities/index.ts b/paperless-backend/src/database/entities/index.ts index 34082d7..75d7d18 100644 --- a/paperless-backend/src/database/entities/index.ts +++ b/paperless-backend/src/database/entities/index.ts @@ -21,3 +21,4 @@ export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity' export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity'; export { UserSettings } from './user-settings.entity'; export { LabelPrintJob } from './label-print-job.entity'; +export { WebhookQueueItem } from './webhook-queue-item.entity'; diff --git a/paperless-backend/src/database/entities/webhook-queue-item.entity.ts b/paperless-backend/src/database/entities/webhook-queue-item.entity.ts new file mode 100644 index 0000000..e3b6505 --- /dev/null +++ b/paperless-backend/src/database/entities/webhook-queue-item.entity.ts @@ -0,0 +1,26 @@ +import { + Entity, + PrimaryColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +/** + * Persistente Warteschlange der vom Paperless-Webhook gemeldeten Dokument-IDs. + * `documentId` ist Primärschlüssel und erzwingt damit, dass jede ID nur einmal + * in der Warteschlange steht (Dedup auf DB-Ebene). Die Tabelle übersteht + * Neustarts; ausstehende IDs werden nach dem Boot weiterverarbeitet. + */ +@Entity('webhook_queue') +export class WebhookQueueItem { + @PrimaryColumn({ type: 'int' }) + documentId!: number; + + @Column({ type: 'varchar', length: 100, nullable: true }) + action!: string | null; + + @Index() + @CreateDateColumn() + createdAt!: Date; +} diff --git a/paperless-backend/src/database/migrations/.gitkeep b/paperless-backend/src/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/paperless-backend/src/database/migrations/1782700000000-CreateWebhookQueue.ts b/paperless-backend/src/database/migrations/1782700000000-CreateWebhookQueue.ts new file mode 100644 index 0000000..aee2c23 --- /dev/null +++ b/paperless-backend/src/database/migrations/1782700000000-CreateWebhookQueue.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Legt die persistente Webhook-Warteschlange an (`webhook_queue`). + * `documentId` ist Primärschlüssel → jede Dokument-ID kommt nur einmal vor. + */ +export class CreateWebhookQueue1782700000000 implements MigrationInterface { + name = 'CreateWebhookQueue1782700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS \`webhook_queue\` ( + \`documentId\` int NOT NULL, + \`action\` varchar(100) NULL, + \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (\`documentId\`), + INDEX \`IDX_webhook_queue_createdAt\` (\`createdAt\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`webhook_queue\``); + } +} diff --git a/paperless-backend/src/email-download/email-download.service.ts b/paperless-backend/src/email-download/email-download.service.ts index a21283d..5ff38cc 100644 --- a/paperless-backend/src/email-download/email-download.service.ts +++ b/paperless-backend/src/email-download/email-download.service.ts @@ -55,11 +55,21 @@ export class EmailDownloadService { } } + private createImapClient(): ImapFlow { + return new ImapFlow({ + host: this.configService.get('IMAP_HOST', ''), + port: this.configService.get('IMAP_PORT', 993), + secure: this.configService.get('IMAP_USE_SSL', 'true') === 'true', + auth: { + user: this.configService.get('IMAP_USERNAME', ''), + pass: this.configService.get('IMAP_PASSWORD', ''), + }, + logger: false, + }); + } + private async fetchAndStore(): Promise { const host = this.configService.get('IMAP_HOST'); - const port = this.configService.get('IMAP_PORT', 993); - const secure = - this.configService.get('IMAP_USE_SSL', 'true') === 'true'; const user = this.configService.get('IMAP_USERNAME'); const pass = this.configService.get('IMAP_PASSWORD'); @@ -72,16 +82,10 @@ export class EmailDownloadService { this.logger.log('E-Mail Fetch Job gestartet.'); - const client = new ImapFlow({ - host, - port, - secure, - auth: { user, pass }, - logger: false, - }); + const client = this.createImapClient(); await client.connect(); - this.logger.log(`Verbunden mit IMAP-Server ${host}:${port}`); + this.logger.log(`Verbunden mit IMAP-Server ${host}.`); const lock = await client.getMailboxLock('INBOX'); try { diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index 022b167..38b2b47 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -12,6 +12,7 @@ import { Task } from '../database/entities/task.entity'; import { PaperlessService } from '../paperless/paperless.service'; import * as QRCode from 'qrcode'; import { EmailPageCacheService } from './email-page-cache.service'; +import { ImapFolderService } from './imap-folder.service'; import { PdfService } from '../preprocessing/pdf.service'; import * as path from 'path'; import * as os from 'os'; @@ -52,6 +53,7 @@ export class EmailImportService { private readonly paperlessService: PaperlessService, private readonly pdfService: PdfService, private readonly pageCache: EmailPageCacheService, + private readonly imapFolderService: ImapFolderService, ) {} async ensurePreviews(emailId: number): Promise { @@ -646,6 +648,18 @@ export class EmailImportService { this.logger.log( `Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`, ); + const emailEntity = await this.emailRepo.findOne({ + where: { Id: firstAtt.EmailMessageId }, + }); + if (emailEntity) { + this.imapFolderService + .moveToImportiert(emailEntity.MessageId) + .catch((err) => + this.logger.error( + 'IMAP-Verschieben fehlgeschlagen: ' + err.message, + ), + ); + } } } diff --git a/paperless-backend/src/email/email.controller.ts b/paperless-backend/src/email/email.controller.ts index dc18b9b..b08776e 100644 --- a/paperless-backend/src/email/email.controller.ts +++ b/paperless-backend/src/email/email.controller.ts @@ -17,6 +17,7 @@ import { Email } from '../database/entities/email.entity'; import { Attachment } from '../database/entities/attachment.entity'; import { Content } from '../database/entities/content.entity'; import { PaperlessService } from '../paperless/paperless.service'; +import { ImapFolderService } from './imap-folder.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -31,6 +32,7 @@ export class EmailController { @InjectRepository(Content) private readonly contentRepo: Repository, private readonly paperlessService: PaperlessService, + private readonly imapFolderService: ImapFolderService, ) {} @Get() @@ -202,7 +204,28 @@ export class EmailController { this.logger.log( `Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`, ); - return { updatedCount, idsUpdated }; + this.imapFolderService + .cleanupImportedEmails() + .catch((err) => + this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message), + ); + + let movedToImportiert = 0; + if (body.includeProcessed) { + const processedEmails = await this.emailRepo.find({ + where: [{ Status: 1 }, { Status: 3 }], + select: ['MessageId'], + }); + const messageIds = processedEmails + .map((e) => e.MessageId) + .filter(Boolean); + movedToImportiert = + await this.imapFolderService.moveProcessedInboxToImportiert( + messageIds, + ); + } + + return { updatedCount, idsUpdated, movedToImportiert }; } catch (error: any) { this.logger.error( `Kritischer Fehler bei checkAttachments: ${error.message}`, diff --git a/paperless-backend/src/email/email.module.ts b/paperless-backend/src/email/email.module.ts index 6b52873..bd202dc 100644 --- a/paperless-backend/src/email/email.module.ts +++ b/paperless-backend/src/email/email.module.ts @@ -9,6 +9,7 @@ import { EmailPageCacheService } from './email-page-cache.service'; import { EmailImportController } from './email-import.controller'; import { EmailImportService } from './email-import.service'; +import { ImapFolderService } from './imap-folder.service'; import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity'; import { Task } from '../database/entities/task.entity'; import { PreprocessingModule } from '../preprocessing/preprocessing.module'; @@ -26,7 +27,7 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module'; PreprocessingModule, ], controllers: [EmailController, EmailImportController], - providers: [EmailImportService, EmailPageCacheService], + providers: [EmailImportService, EmailPageCacheService, ImapFolderService], exports: [EmailPageCacheService], }) export class EmailModule {} diff --git a/paperless-backend/src/email/imap-folder.service.ts b/paperless-backend/src/email/imap-folder.service.ts new file mode 100644 index 0000000..8f5496a --- /dev/null +++ b/paperless-backend/src/email/imap-folder.service.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; +import { ImapFlow } from 'imapflow'; + +@Injectable() +export class ImapFolderService { + private readonly logger = new Logger(ImapFolderService.name); + + constructor(private readonly configService: ConfigService) {} + + private createClient(): ImapFlow { + return new ImapFlow({ + host: this.configService.get('IMAP_HOST', ''), + port: this.configService.get('IMAP_PORT', 993), + secure: this.configService.get('IMAP_USE_SSL', 'true') === 'true', + auth: { + user: this.configService.get('IMAP_USERNAME', ''), + pass: this.configService.get('IMAP_PASSWORD', ''), + }, + logger: false, + }); + } + + @Cron('0 3 * * *', { timeZone: 'Europe/Berlin' }) + async cleanupImportedEmails(): Promise { + if (!this.configService.get('IMAP_HOST')) return; + const importedFolder = this.configService.get( + 'IMAP_IMPORTED_FOLDER', + 'importiert', + ); + const trashFolder = this.configService.get( + 'IMAP_TRASH_FOLDER', + 'Trash', + ); + const client = this.createClient(); + try { + await client.connect(); + + // E-Mails älter als 90 Tage in Papierkorb verschieben + try { + await client.mailboxOpen(importedFolder); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 90); + const oldUids = await client.search({ before: cutoff }, { uid: true }); + if (Array.isArray(oldUids) && oldUids.length > 0) { + await client.messageMove(oldUids, trashFolder, { uid: true }); + this.logger.log( + `${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`, + ); + } + } catch (err: any) { + this.logger.warn( + `Bereinigung "${importedFolder}" nicht möglich: ${err.message}`, + ); + } + + // Papierkorb leeren + try { + await client.mailboxOpen(trashFolder); + const trashUids = await client.search({ all: true }, { uid: true }); + if (Array.isArray(trashUids) && trashUids.length > 0) { + await client.messageDelete(trashUids, { uid: true }); + this.logger.log( + `${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`, + ); + } + } catch (err: any) { + this.logger.warn( + `Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`, + ); + } + } catch (err: any) { + this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`); + } finally { + await client.logout().catch(() => {}); + } + } + + async moveProcessedInboxToImportiert(messageIds: string[]): Promise { + if (!this.configService.get('IMAP_HOST') || messageIds.length === 0) + return 0; + + const importedFolder = this.configService.get( + 'IMAP_IMPORTED_FOLDER', + 'importiert', + ); + const client = this.createClient(); + let movedCount = 0; + + try { + await client.connect(); + + const mailboxes = await client.list(); + if (!mailboxes.some((m) => m.path === importedFolder)) { + await client.mailboxCreate(importedFolder); + this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`); + } + + const status = await client.mailboxOpen('INBOX'); + if (status.exists === 0) return 0; + + const normalize = (id: string) => id.replace(/^<|>$/g, '').toLowerCase(); + const idSet = new Set(messageIds.map(normalize)); + const uidsToMove: number[] = []; + + for await (const msg of client.fetch('1:*', { + uid: true, + envelope: true, + })) { + const msgId = msg.envelope?.messageId; + if (msgId && idSet.has(normalize(msgId))) { + uidsToMove.push(msg.uid); + } + } + + if (uidsToMove.length > 0) { + await client.messageMove(uidsToMove, importedFolder, { uid: true }); + movedCount = uidsToMove.length; + this.logger.log( + `${movedCount} E-Mail(s) aus INBOX → "${importedFolder}" verschoben.`, + ); + } + } catch (err: any) { + this.logger.error( + `moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`, + ); + } finally { + await client.logout().catch(() => {}); + } + + return movedCount; + } + + async moveToImportiert(messageId: string): Promise { + if (!this.configService.get('IMAP_HOST')) return; + + const importedFolder = this.configService.get( + 'IMAP_IMPORTED_FOLDER', + 'importiert', + ); + const client = this.createClient(); + try { + await client.connect(); + + const mailboxes = await client.list(); + if (!mailboxes.some((m) => m.path === importedFolder)) { + await client.mailboxCreate(importedFolder); + this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`); + } + + await client.mailboxOpen('INBOX'); + const uids = await client.search( + { header: { 'message-id': messageId } }, + { uid: true }, + ); + if (Array.isArray(uids) && uids.length > 0) { + await client.messageMove(uids, importedFolder, { uid: true }); + this.logger.log( + `E-Mail ${messageId} → "${importedFolder}" verschoben.`, + ); + } else { + this.logger.warn( + `E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`, + ); + } + } catch (err: any) { + this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`); + } finally { + await client.logout().catch(() => {}); + } + } +} diff --git a/paperless-backend/src/paperless/paperless-processor.service.ts b/paperless-backend/src/paperless/paperless-processor.service.ts index 9a54b6f..0426529 100644 --- a/paperless-backend/src/paperless/paperless-processor.service.ts +++ b/paperless-backend/src/paperless/paperless-processor.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -8,6 +7,8 @@ import { DocumentType } from '../database/entities/document-type.entity'; import { PaperlessService } from './paperless.service'; import { PostprocessingService } from '../postprocessing/postprocessing.service'; +const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager" + @Injectable() export class PaperlessProcessorService { private readonly logger = new Logger(PaperlessProcessorService.name); @@ -22,11 +23,13 @@ export class PaperlessProcessorService { private readonly docFieldRepo: Repository, ) {} - @Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *') + // Manueller Batch-Lauf ("alle paperlessmanager-Dokumente neu durchlaufen"). + // Die laufende Verarbeitung erfolgt ereignisgesteuert über den Webhook + // (processDocumentById), daher ist hier kein @Cron-Trigger mehr gesetzt. async processDocuments() { try { const response = await this.paperlessService.getDocuments({ - tags__id__all: 16, + tags__id__all: PAPERLESSMANAGER_TAG_ID, page_size: 9999, }); const documents: any[] = Array.isArray(response) @@ -38,17 +41,12 @@ export class PaperlessProcessorService { const validFieldIds = new Set(customFields.map((f: any) => f.id)); this.logger.log( - `Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`, + `Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: ${PAPERLESSMANAGER_TAG_ID}).`, ); for (const doc of documents) { try { - const updatedDoc = await this.processSingleDocument( - doc, - validFieldIds, - ); - // Postprocessing nach dem Speichern evaluieren - await this.postprocessingService.evaluate(updatedDoc || doc); + await this.processAndEvaluate(doc, validFieldIds); } catch (innerErr: any) { this.logger.error( `Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`, @@ -65,6 +63,38 @@ export class PaperlessProcessorService { } } + /** + * Verarbeitet ein einzelnes Dokument anhand seiner ID – die ereignisgesteuerte + * Variante des früheren Cron-Jobs (vom Paperless-Webhook aufgerufen). Es wird + * jedes gemeldete Dokument verarbeitet, unabhängig vom Tag "paperlessmanager". + */ + async processDocumentById( + documentId: number, + ): Promise<{ processed: boolean; reason?: string }> { + const doc = await this.paperlessService.getDocument(documentId); + + const customFields = await this.paperlessService.getCustomFields(); + const validFieldIds = new Set(customFields.map((f: any) => f.id)); + + await this.processAndEvaluate(doc, validFieldIds); + this.logger.log(`Dokument ${documentId} per Webhook verarbeitet.`); + return { processed: true }; + } + + /** + * Reichert ein Dokument an (Tags, Pflichtfelder, Titel) und evaluiert + * anschließend das Postprocessing. Gemeinsame Logik von Batch-Lauf und Webhook. + */ + private async processAndEvaluate( + doc: any, + validFieldIds: Set, + ): Promise { + const updatedDoc = await this.processSingleDocument(doc, validFieldIds); + // Postprocessing nach dem Speichern evaluieren + await this.postprocessingService.evaluate(updatedDoc || doc); + return updatedDoc; + } + private async processSingleDocument( doc: any, validFieldIds: Set, @@ -151,7 +181,7 @@ export class PaperlessProcessorService { doc.correspondent !== null && doc.correspondent !== undefined; break; case 2: - isFilled = !!doc.created || !!doc.created_date; + isFilled = !!doc.created; break; case 3: isFilled = diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index 0b719cc..e9cec6f 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -155,7 +155,7 @@ export class PaperlessController { asn: doc.archive_serial_number, documentType: doc.document_type, correspondent: doc.correspondent, - created: doc.created_date, + created: doc.created, added: doc.added, tags: doc.tags, customFields: doc.custom_fields, @@ -179,7 +179,7 @@ export class PaperlessController { asn: doc.archive_serial_number, documentType: doc.document_type, correspondent: doc.correspondent, - created: doc.created_date, + created: doc.created, added: doc.added, tags: doc.tags, customFields: doc.custom_fields, @@ -338,7 +338,7 @@ export class PaperlessController { docDate.getHours() * 60 * 60 * 1000, ); } - oldDocument.created_date = docDate.toISOString().split('T')[0]; + oldDocument.created = docDate.toISOString(); } const cfDefinitions = await this.paperlessService.getCustomFields(); @@ -390,7 +390,7 @@ export class PaperlessController { 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 === 2) isFieldValid = oldDocument.created !== null; if (req.Type === 3) isFieldValid = oldDocument.archive_serial_number !== null; if (req.Type === 4) @@ -446,10 +446,8 @@ export class PaperlessController { ); } } - titleTemplate = titleTemplate.replace( - '{{DATE}}', - oldDocument.created_date, - ); + const createdDatePart = String(oldDocument.created ?? '').split('T')[0]; + titleTemplate = titleTemplate.replace('{{DATE}}', createdDatePart); oldDocument.title = titleTemplate; } } else { @@ -471,6 +469,9 @@ export class PaperlessController { } } + // Veraltetes Feld nicht mit-PATCHen (löst Paperless-Deprecation-Warnung aus) + delete oldDocument.created_date; + await this.paperlessService.updateDocument(documentId, oldDocument); return { success: true }; } diff --git a/paperless-backend/src/paperless/paperless.module.ts b/paperless-backend/src/paperless/paperless.module.ts index eb5db29..6b6f4c6 100644 --- a/paperless-backend/src/paperless/paperless.module.ts +++ b/paperless-backend/src/paperless/paperless.module.ts @@ -32,6 +32,6 @@ import { AuthModule } from '../auth/auth.module'; PaperlessProcessorService, PaperlessTaskProcessorService, ], - exports: [PaperlessService], + exports: [PaperlessService, PaperlessProcessorService], }) export class PaperlessModule {} diff --git a/paperless-backend/src/paperless/paperless.service.ts b/paperless-backend/src/paperless/paperless.service.ts index 4a7b33d..bb9cfeb 100644 --- a/paperless-backend/src/paperless/paperless.service.ts +++ b/paperless-backend/src/paperless/paperless.service.ts @@ -250,6 +250,14 @@ export class PaperlessService { return response.data; } + async getNotes( + id: number, + ): Promise> { + const response = await this.client.get(`/documents/${id}/notes/`); + const data = response.data; + return Array.isArray(data) ? data : (data?.results ?? []); + } + async checksumExists(checksum: string): Promise { const response = await this.client.get('/documents/', { params: { checksum__iexact: checksum }, diff --git a/paperless-backend/src/webhook/webhook-queue.service.spec.ts b/paperless-backend/src/webhook/webhook-queue.service.spec.ts new file mode 100644 index 0000000..469ad22 --- /dev/null +++ b/paperless-backend/src/webhook/webhook-queue.service.spec.ts @@ -0,0 +1,150 @@ +// PaperlessProcessorService zieht über die Postprocessing-Kette das ESM-Paket +// "webdav" nach, das Jest nicht transformiert. Für diesen Unit-Test ersetzen wir +// das Modul durch eine Dummy-Klasse – der Service erhält seine Abhängigkeiten +// ohnehin als Mocks injiziert. +jest.mock('../paperless/paperless-processor.service', () => ({ + PaperlessProcessorService: class {}, +})); + +import { WebhookQueueService } from './webhook-queue.service'; + +/** + * Baut ein Mock-Repository, das die `webhook_queue`-Tabelle durch ein einfaches + * FIFO-Array simuliert: `INSERT IGNORE` (Dedup), FIFO-`find` und `delete`. + */ +function createQueueRepoMock() { + const store: number[] = []; + return { + store, + createQueryBuilder: jest.fn(() => ({ + insert: () => ({ + into: () => ({ + values: (v: { documentId: number }) => ({ + orIgnore: () => ({ + execute: () => { + const added = !store.includes(v.documentId); + if (added) store.push(v.documentId); + return Promise.resolve({ + raw: { affectedRows: added ? 1 : 0 }, + }); + }, + }), + }), + }), + }), + })), + find: jest.fn(() => + Promise.resolve( + store.length + ? [{ documentId: store[0], action: null, createdAt: new Date() }] + : [], + ), + ), + delete: jest.fn((criteria: { documentId: number }) => { + const idx = store.indexOf(criteria.documentId); + if (idx >= 0) store.splice(idx, 1); + return Promise.resolve({ affected: 1 }); + }), + count: jest.fn(() => Promise.resolve(store.length)), + }; +} + +describe('WebhookQueueService', () => { + let processor: { processDocumentById: jest.Mock }; + let settingRepo: { findOneBy: jest.Mock; create: jest.Mock; save: jest.Mock }; + let queueRepo: ReturnType; + let service: WebhookQueueService; + + beforeEach(() => { + processor = { + processDocumentById: jest.fn().mockResolvedValue({ processed: true }), + }; + settingRepo = { + findOneBy: jest.fn().mockResolvedValue(null), + create: jest.fn((x: unknown) => x), + save: jest.fn().mockResolvedValue(undefined), + }; + queueRepo = createQueueRepoMock(); + service = new WebhookQueueService( + processor as never, + settingRepo as never, + queueRepo as never, + ); + }); + + it('reiht jede ID nur einmal ein (Dedup)', async () => { + await service.enqueue(5); + await service.enqueue(5); + expect(queueRepo.store).toEqual([5]); + expect(await service.count()).toBe(1); + }); + + it('verarbeitet alle eingereihten IDs und leert die Warteschlange', async () => { + await service.enqueue(1); + await service.enqueue(2); + + await service.processQueue(); + + expect(processor.processDocumentById).toHaveBeenCalledWith(1); + expect(processor.processDocumentById).toHaveBeenCalledWith(2); + expect(processor.processDocumentById).toHaveBeenCalledTimes(2); + expect(queueRepo.store).toEqual([]); + }); + + it('entfernt die ID vor Verarbeitungsbeginn aus der Tabelle', async () => { + let containedWhileProcessing = true; + processor.processDocumentById.mockImplementation((id: number) => { + containedWhileProcessing = queueRepo.store.includes(id); + return Promise.resolve({ processed: true }); + }); + + await service.enqueue(42); + await service.processQueue(); + + // Beim Verarbeitungsstart war die ID bereits aus der Tabelle entfernt. + expect(containedWhileProcessing).toBe(false); + }); + + it('arbeitet eine während der Verarbeitung erneut eingereihte ID erneut ab', async () => { + let firstRun = true; + processor.processDocumentById.mockImplementation(async (id: number) => { + // Beim ersten Lauf feuert der Webhook erneut, während verarbeitet wird. + if (firstRun && id === 7) { + firstRun = false; + await service.enqueue(7); + } + return { processed: true }; + }); + + await service.enqueue(7); + await service.processQueue(); + + expect(processor.processDocumentById).toHaveBeenCalledTimes(2); + expect(queueRepo.store).toEqual([]); + }); + + it('startet keinen zweiten Durchlauf parallel (isProcessing-Guard)', async () => { + let resolveFirst: (() => void) | undefined; + let signalStarted!: () => void; + const started = new Promise((res) => { + signalStarted = res; + }); + processor.processDocumentById.mockImplementation( + () => + new Promise<{ processed: boolean }>((res) => { + resolveFirst = () => res({ processed: true }); + signalStarted(); // erste Verarbeitung läuft und blockiert hier + }), + ); + + await service.enqueue(1); + const firstRun = service.processQueue(); // blockiert in processDocumentById + await started; // warten, bis der erste Lauf tatsächlich verarbeitet + await service.processQueue(); // muss sofort zurückkehren (isProcessing) + + expect(processor.processDocumentById).toHaveBeenCalledTimes(1); + + resolveFirst?.(); + await firstRun; + }); +}); diff --git a/paperless-backend/src/webhook/webhook-queue.service.ts b/paperless-backend/src/webhook/webhook-queue.service.ts new file mode 100644 index 0000000..e7b5272 --- /dev/null +++ b/paperless-backend/src/webhook/webhook-queue.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PaperlessProcessorService } from '../paperless/paperless-processor.service'; +import { Setting } from '../database/entities/setting.entity'; +import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity'; + +// Tag (Schlüssel) des Settings-Eintrags, der den letzten Webhook-Aufruf festhält. +const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call'; + +// Prüfintervall der Warteschlange (sehr kurz). Über ENV überschreibbar. +const QUEUE_INTERVAL_MS = Number(process.env.WEBHOOK_QUEUE_INTERVAL_MS) || 1000; + +interface WebhookStatusInfo { + documentId: number | null; + action?: string; + status: 'queued' | 'processed' | 'error' | 'bad-request'; + reason?: string; + message?: string; +} + +/** + * Entkoppelt den Webhook-Empfang von der Verarbeitung: Eingehende Dokument-IDs + * werden in eine **persistente**, deduplizierte Warteschlange (`webhook_queue`) + * gelegt und von einem separaten Intervall-Prozess **sequenziell** (ohne + * Überschneidung) abgearbeitet. So führt mehrfaches schnelles Speichern + * desselben Dokuments nicht zu parallelen Läufen. + * + * Verhalten: + * - Jede ID kommt nur **einmal** in der Warteschlange vor (Dedup via Primär- + * schlüssel `documentId` / `INSERT IGNORE`). + * - Beim Verarbeitungsstart wird die ID **sofort** aus der Tabelle entfernt; ein + * erneutes Feuern während der Verarbeitung reiht sie wieder ein (ein weiterer + * Lauf folgt danach). + * - Es läuft immer nur eine Verarbeitung gleichzeitig (`isProcessing`-Guard). + * + * Da die Warteschlange in der Datenbank liegt, überstehen ausstehende IDs einen + * Neustart und werden nach dem Boot weiterverarbeitet. + */ +@Injectable() +export class WebhookQueueService { + private readonly logger = new Logger(WebhookQueueService.name); + private isProcessing = false; + + constructor( + private readonly paperlessProcessor: PaperlessProcessorService, + @InjectRepository(Setting) + private readonly settingRepo: Repository, + @InjectRepository(WebhookQueueItem) + private readonly queueRepo: Repository, + ) {} + + /** Anzahl der aktuell wartenden Dokument-IDs. */ + count(): Promise { + return this.queueRepo.count(); + } + + /** + * Reiht eine Dokument-ID zur Verarbeitung ein. Ist die ID bereits in der + * Warteschlange, wird sie nicht erneut hinzugefügt (Dedup über den Primär- + * schlüssel; `INSERT IGNORE` ist atomar und race-sicher). + */ + async enqueue(documentId: number, action?: string): Promise { + const result = await this.queueRepo + .createQueryBuilder() + .insert() + .into(WebhookQueueItem) + .values({ documentId, action: action ?? null }) + .orIgnore() + .execute(); + + const added = + ((result.raw as { affectedRows?: number })?.affectedRows ?? 0) > 0; + if (added) { + this.logger.log(`Dokument ${documentId} eingereiht.`); + } else { + this.logger.log( + `Dokument ${documentId} ist bereits in der Warteschlange – nicht erneut hinzugefügt.`, + ); + } + await this.recordStatus({ documentId, action, status: 'queued' }); + } + + /** + * Prüft in kurzen Abständen die Warteschlange und arbeitet vorhandene IDs + * nacheinander ab. Ein bereits laufender Durchlauf wird nicht doppelt + * gestartet (kein paralleles Verarbeiten). + */ + @Interval(QUEUE_INTERVAL_MS) + async processQueue(): Promise { + if (this.isProcessing) return; + this.isProcessing = true; + try { + // Solange Einträge vorhanden sind, einzeln und sequenziell abarbeiten. + for (;;) { + // Ältesten Eintrag (FIFO) holen ... + const [next] = await this.queueRepo.find({ + order: { createdAt: 'ASC' }, + take: 1, + }); + if (!next) break; + // ... und SOFORT (vor Verarbeitungsstart) aus der Tabelle entfernen. + await this.queueRepo.delete({ documentId: next.documentId }); + await this.handleDocument(next.documentId); + } + } finally { + this.isProcessing = false; + } + } + + private async handleDocument(documentId: number): Promise { + try { + await this.paperlessProcessor.processDocumentById(documentId); + this.logger.log(`Dokument ${documentId} aus Warteschlange verarbeitet.`); + await this.recordStatus({ documentId, status: 'processed' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + `Fehler bei der Verarbeitung von Dokument ${documentId}: ${message}`, + ); + await this.recordStatus({ documentId, status: 'error', message }); + } + } + + /** + * Liefert den letzten festgehaltenen Webhook-Status sowie die aktuelle + * Warteschlangen-Größe. + */ + async getStatus(): Promise<{ lastCall: unknown; queueSize: number }> { + const setting = await this.settingRepo.findOneBy({ + Tag: LAST_WEBHOOK_CALL_TAG, + }); + let lastCall: unknown = null; + if (setting?.Wert) { + try { + lastCall = JSON.parse(setting.Wert); + } catch { + lastCall = { raw: setting.Wert }; + } + } + return { lastCall, queueSize: await this.queueRepo.count() }; + } + + /** + * Hält den letzten Webhook-Vorgang in der Settings-Tabelle fest + * (Tag "last_webhook_call"). Fehler beim Speichern werden nur geloggt. + */ + async recordStatus(info: WebhookStatusInfo): Promise { + try { + const value = JSON.stringify({ + at: new Date().toISOString(), + documentId: info.documentId, + action: info.action ?? null, + status: info.status, + ...(info.reason ? { reason: info.reason } : {}), + ...(info.message ? { message: info.message.slice(0, 100) } : {}), + }); + + let setting = await this.settingRepo.findOneBy({ + Tag: LAST_WEBHOOK_CALL_TAG, + }); + if (!setting) { + setting = this.settingRepo.create({ + Typ: 0, + Tag: LAST_WEBHOOK_CALL_TAG, + Wert: value, + }); + } else { + setting.Wert = value; + } + await this.settingRepo.save(setting); + } catch (err) { + this.logger.error( + 'Konnte Webhook-Status nicht in den Settings speichern', + err instanceof Error ? err.stack : String(err), + ); + } + } +} diff --git a/paperless-backend/src/webhook/webhook.controller.ts b/paperless-backend/src/webhook/webhook.controller.ts index 8154711..07eb00e 100644 --- a/paperless-backend/src/webhook/webhook.controller.ts +++ b/paperless-backend/src/webhook/webhook.controller.ts @@ -1,36 +1,85 @@ import { Controller, Post, + Get, Body, Logger, HttpCode, HttpStatus, + UseGuards, + BadRequestException, } from '@nestjs/common'; -import { Public } from '../auth/public.decorator'; +import { ApiKeyGuard } from '../auth/api-key.guard'; +import { WebhookQueueService } from './webhook-queue.service'; + export interface PaperlessWebhookPayload { - document_id: number; - action: string; + doc_url?: string; + document_id?: number; // optional, für manuelles Testen + action?: string; [key: string]: any; } -@Controller('webhook') +@Controller('api/webhook') export class WebhookController { private readonly logger = new Logger(WebhookController.name); - @Public() + constructor(private readonly webhookQueue: WebhookQueueService) {} + + @UseGuards(ApiKeyGuard) @Post('paperless') @HttpCode(HttpStatus.OK) - async handlePaperlessWebhook( - @Body() payload: PaperlessWebhookPayload, - ): Promise<{ status: string }> { + async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) { + const documentId = this.extractDocumentId(payload); + if (!documentId) { + this.logger.warn( + `Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`, + ); + await this.webhookQueue.recordStatus({ + documentId: null, + action: payload.action, + status: 'bad-request', + }); + throw new BadRequestException( + 'Keine Dokument-ID aus doc_url/document_id ermittelbar', + ); + } + this.logger.log( - `Webhook empfangen: action=${payload.action}, document=${payload.document_id}`, + `Webhook: action=${payload.action}, document=${documentId} → eingereiht`, ); + // Sofort einreihen und antworten; die eigentliche Verarbeitung übernimmt + // der separate Queue-Prozess (sequenziell, dedupliziert). + await this.webhookQueue.enqueue(documentId, payload.action); + return { status: 'queued', documentId }; + } - // TODO: Business-Logik für verschiedene Webhook-Events - // - document_updated → Felder prüfen, Postprocessing auslösen - // - document_consumed → GoBD-Archivierung prüfen + /** + * Liefert Informationen zum letzten Webhook-Vorgang (Zeitpunkt, Dokument, + * Ergebnis) und die aktuelle Warteschlangen-Größe. Über die globalen Guards + * per JWT oder API-Key zugänglich. + */ + @Get('status') + async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> { + return this.webhookQueue.getStatus(); + } - return { status: 'received' }; + /** + * Ermittelt die Dokument-ID aus dem Webhook-Payload. Bevorzugt wird das + * optionale Feld `document_id` (für manuelles Testen), andernfalls wird die + * ID aus dem Paperless-Platzhalter `{{doc_url}}` extrahiert + * (z.B. ".../documents/123/"). + */ + private extractDocumentId(payload: PaperlessWebhookPayload): number | null { + if ( + payload?.document_id != null && + !Number.isNaN(Number(payload.document_id)) + ) { + return Number(payload.document_id); + } + if (typeof payload?.doc_url === 'string') { + const m = payload.doc_url.match(/\/documents\/(\d+)/); + if (m) return Number(m[1]); + } + return null; } } diff --git a/paperless-backend/src/webhook/webhook.module.ts b/paperless-backend/src/webhook/webhook.module.ts index b004812..68af4c8 100644 --- a/paperless-backend/src/webhook/webhook.module.ts +++ b/paperless-backend/src/webhook/webhook.module.ts @@ -1,7 +1,19 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { WebhookController } from './webhook.controller'; +import { WebhookQueueService } from './webhook-queue.service'; +import { PaperlessModule } from '../paperless/paperless.module'; +import { AuthModule } from '../auth/auth.module'; +import { Setting } from '../database/entities/setting.entity'; +import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity'; @Module({ + imports: [ + TypeOrmModule.forFeature([Setting, WebhookQueueItem]), + PaperlessModule, + AuthModule, + ], controllers: [WebhookController], + providers: [WebhookQueueService], }) export class WebhookModule {} diff --git a/paperless-backend/src/zahlung/zahlung.controller.ts b/paperless-backend/src/zahlung/zahlung.controller.ts new file mode 100644 index 0000000..c627acc --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Put, Param, Body, Query } from '@nestjs/common'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; +import { ZahlungService, type ZahlungFilter } from './zahlung.service'; + +@Controller('api/zahlung') +@RequirePermissions(Permission.VIEW_ZAHLUNG) +export class ZahlungController { + constructor(private readonly zahlungService: ZahlungService) {} + + @Get('documents') + getDocuments( + @Query('page') page = '1', + @Query('pageSize') pageSize = '25', + @Query('filter') filter: ZahlungFilter = 'ausstehend', + ) { + return this.zahlungService.getZahlungDocuments( + parseInt(page, 10), + Math.min(parseInt(pageSize, 10), 100), + filter, + ); + } + + @Put('documents/:id/zahlung') + setZahlung(@Param('id') id: string, @Body('value') value: string | null) { + return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null); + } + + @Get('options') + getOptions() { + return this.zahlungService.getZahlungOptions(); + } +} diff --git a/paperless-backend/src/zahlung/zahlung.module.ts b/paperless-backend/src/zahlung/zahlung.module.ts new file mode 100644 index 0000000..4ad660d --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DocumentType } from '../database/entities/document-type.entity'; +import { PaperlessModule } from '../paperless/paperless.module'; +import { ZahlungController } from './zahlung.controller'; +import { ZahlungService } from './zahlung.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule], + controllers: [ZahlungController], + providers: [ZahlungService], +}) +export class ZahlungModule {} diff --git a/paperless-backend/src/zahlung/zahlung.service.ts b/paperless-backend/src/zahlung/zahlung.service.ts new file mode 100644 index 0000000..aeb8fbc --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.service.ts @@ -0,0 +1,138 @@ +import { Injectable, Logger, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DocumentType } from '../database/entities/document-type.entity'; +import { PaperlessService } from '../paperless/paperless.service'; + +const FREIGABE_FIELD_ID = 15; +const ZAHLUNG_FIELD_ID = 16; +const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; + +export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle'; + +@Injectable() +export class ZahlungService { + private readonly logger = new Logger(ZahlungService.name); + + constructor( + @InjectRepository(DocumentType) + private readonly documentTypeRepo: Repository, + private readonly paperlessService: PaperlessService, + ) {} + + async getZahlungDocuments( + page: number, + pageSize: number, + filter: ZahlungFilter, + ) { + const docTypes = await this.documentTypeRepo.find({ + where: { FreigabeErforderlich: true as any }, + }); + + if (docTypes.length === 0) { + return { count: 0, results: [] }; + } + + const docTypeIds = docTypes.map((dt) => dt.DocumentTypeId).join(','); + + const params: Record = { + page: 1, + page_size: 9999, + document_type__id__in: docTypeIds, + ordering: '-created', + truncate_content: true, + }; + + let allDocs: any[]; + try { + const result = await this.paperlessService.getDocuments(params); + allDocs = result.results ?? []; + } catch (err: any) { + this.logger.warn( + 'Fehler beim Laden der Belege für Zahlung', + err?.message, + ); + return { count: 0, results: [] }; + } + + const filtered = this.applyFilter(allDocs, filter); + const start = (page - 1) * pageSize; + return { + count: filtered.length, + results: filtered.slice(start, start + pageSize), + }; + } + + private applyFilter(docs: any[], filter: ZahlungFilter): any[] { + if (filter === 'alle') return docs; + + return docs.filter((doc: any) => { + const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID); + const istFreigegeben = freigabeValue === FREIGABE_WERT_FREIGEGEBEN; + + if (filter === 'freigegeben') return istFreigegeben; + + // 'ausstehend': freigegeben aber noch nicht bezahlt + const zahlungValue = this.getCfValue(doc, ZAHLUNG_FIELD_ID); + return istFreigegeben && !zahlungValue; + }); + } + + private getCfValue(doc: any, fieldId: number): string | null { + const cf = (doc.custom_fields ?? []).find((f: any) => f.field === fieldId); + if (!cf || cf.value === null || cf.value === undefined || cf.value === '') + return null; + if (typeof cf.value === 'object') { + return ( + String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null + ); + } + return String(cf.value); + } + + async setZahlung(documentId: number, value: string | null) { + const doc = await this.paperlessService.getDocument(documentId); + + const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID); + if (freigabeValue !== FREIGABE_WERT_FREIGEGEBEN) { + throw new ForbiddenException( + 'Zahlung kann nur für freigegebene Belege gesetzt werden', + ); + } + + const customFields: any[] = [...(doc.custom_fields ?? [])]; + const existing = customFields.find( + (f: any) => f.field === ZAHLUNG_FIELD_ID, + ); + if (existing) { + existing.value = value; + } else if (value !== null && value !== '') { + customFields.push({ field: ZAHLUNG_FIELD_ID, value }); + } + + await this.paperlessService.updateDocument(documentId, { + custom_fields: customFields, + }); + return { success: true }; + } + + async getZahlungOptions(): Promise<{ id: string; label: string }[]> { + const fields = await this.paperlessService.getCustomFields(); + const field = fields.find((f: any) => f.id === ZAHLUNG_FIELD_ID); + if (!field) return []; + + const rawOptions: any[] = field.extra_data?.select_options ?? []; + return rawOptions + .filter((o) => o !== null && o !== undefined && o !== '') + .map((o) => { + if (typeof o === 'object') { + return { + id: String(o.id ?? o.value ?? o.label ?? ''), + label: String(o.label ?? o.name ?? o.id ?? ''), + }; + } + return { id: String(o), label: String(o) }; + }) + .filter((o) => o.id !== ''); + } +} diff --git a/paperless-frontend/src/App.tsx b/paperless-frontend/src/App.tsx index 45cd76b..755e4ee 100644 --- a/paperless-frontend/src/App.tsx +++ b/paperless-frontend/src/App.tsx @@ -21,6 +21,7 @@ const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage')); const LoginPage = lazy(() => import('./pages/LoginPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage')); const FreigabePage = lazy(() => import('./pages/FreigabePage')); +const ZahlungPage = lazy(() => import('./pages/ZahlungPage')); import { Permission } from './auth/permissions'; function UnauthorizedPage() { @@ -75,6 +76,10 @@ function ThemedApp() { token: { colorPrimary: '#1677ff', borderRadius: 6, + // Gleiche Schriftfamilie wie der Body (index.css), damit AntD-Eingaben + // – z.B. das Select-Suchfeld – nicht im AntD-Default-Stack abweichen. + fontFamily: + "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", ...(isDark ? {} : { @@ -133,6 +138,7 @@ function ThemedApp() { } /> } /> } /> + } /> } /> diff --git a/paperless-frontend/src/api/client.ts b/paperless-frontend/src/api/client.ts index 6623c72..75eb460 100644 --- a/paperless-frontend/src/api/client.ts +++ b/paperless-frontend/src/api/client.ts @@ -1,8 +1,12 @@ -import axios from 'axios'; -import { getAccessToken } from '../auth/oidc'; +import axios, { type InternalAxiosRequestConfig } from 'axios'; +import { getAccessToken, renewToken } from '../auth/oidc'; import { triggerLoginRedirect } from '../auth/sessionRedirect'; import { getEnv } from '../utils/env'; +// Markiert eine bereits einmal wiederholte Anfrage, um Endlosschleifen zu +// vermeiden. +type RetryConfig = InternalAxiosRequestConfig & { _retried?: boolean }; + const api = axios.create({ baseURL: getEnv('VITE_API_URL') || '', timeout: 30000, @@ -19,7 +23,18 @@ api.interceptors.request.use(async (config) => { api.interceptors.response.use( (response) => response, async (error) => { - if (error.response?.status === 401) { + const original = error.config as RetryConfig | undefined; + if (error.response?.status === 401 && original && !original._retried) { + original._retried = true; + // Erst still erneuern und die Anfrage einmal wiederholen, bevor wir den + // Nutzer zu einer kompletten Neuanmeldung zwingen. + const user = await renewToken(); + if (user?.access_token) { + original.headers.Authorization = `Bearer ${user.access_token}`; + return api(original); + } + // Erneuerung fehlgeschlagen (Refresh-Token ungültig/abgelaufen) → + // echte Neuanmeldung nötig. await triggerLoginRedirect(); } return Promise.reject(error); diff --git a/paperless-frontend/src/api/emails.ts b/paperless-frontend/src/api/emails.ts index b006378..f7c0915 100644 --- a/paperless-frontend/src/api/emails.ts +++ b/paperless-frontend/src/api/emails.ts @@ -44,7 +44,7 @@ export const emailsApi = { api.post<{ message: string }>('/api/emails/fetch').then((r) => r.data), checkAttachments: (includeProcessed = false) => - api.post<{ updatedCount: number; idsUpdated: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data), + api.post<{ updatedCount: number; idsUpdated: number; movedToImportiert: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data), updateStatus: (id: number, status: number) => api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data), diff --git a/paperless-frontend/src/api/freigabe.ts b/paperless-frontend/src/api/freigabe.ts index 1f4163e..607d0a0 100644 --- a/paperless-frontend/src/api/freigabe.ts +++ b/paperless-frontend/src/api/freigabe.ts @@ -4,7 +4,6 @@ export interface FreigabeDocument { id: number; title: string; created: string; - created_date: string; correspondent: number | null; document_type: number | null; archive_serial_number: number | null; diff --git a/paperless-frontend/src/api/settings.ts b/paperless-frontend/src/api/settings.ts index 89a12d5..a9c8124 100644 --- a/paperless-frontend/src/api/settings.ts +++ b/paperless-frontend/src/api/settings.ts @@ -208,6 +208,8 @@ export interface AgrarmonitorPollingConfig { tagHochgeladen: string; linkField: string; tagManuell: string; + importWartezeitMinuten: string; + notizMarker: string; } export interface AgrarmonitorPollingResult { diff --git a/paperless-frontend/src/api/webhook.ts b/paperless-frontend/src/api/webhook.ts new file mode 100644 index 0000000..db45565 --- /dev/null +++ b/paperless-frontend/src/api/webhook.ts @@ -0,0 +1,20 @@ +import api from './client'; + +export interface WebhookLastCall { + at: string; // ISO-Zeitstempel + documentId: number | null; + action: string | null; + status: 'queued' | 'processed' | 'error' | 'bad-request' | string; + reason?: string; + message?: string; +} + +export interface WebhookStatus { + lastCall: WebhookLastCall | null; + queueSize?: number; +} + +export const webhookApi = { + getStatus: () => + api.get('/api/webhook/status').then((r) => r.data), +}; diff --git a/paperless-frontend/src/api/zahlung.ts b/paperless-frontend/src/api/zahlung.ts new file mode 100644 index 0000000..9079974 --- /dev/null +++ b/paperless-frontend/src/api/zahlung.ts @@ -0,0 +1,39 @@ +import api from './client'; + +export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle'; + +export interface ZahlungDocument { + id: number; + title: string; + created: string; + correspondent: number | null; + document_type: number | null; + archive_serial_number: number | null; + tags: number[]; + custom_fields: { field: number; value: any }[]; +} + +export interface ZahlungOption { + id: string; + label: string; +} + +export interface ZahlungResult { + count: number; + results: ZahlungDocument[]; +} + +export const zahlungApi = { + getDocuments: (page = 1, pageSize = 25, filter: ZahlungFilter = 'ausstehend') => + api + .get('/api/zahlung/documents', { params: { page, pageSize, filter } }) + .then((r) => r.data), + + setZahlung: (docId: number, value: string | null) => + api + .put<{ success: boolean }>(`/api/zahlung/documents/${docId}/zahlung`, { value }) + .then((r) => r.data), + + getOptions: () => + api.get('/api/zahlung/options').then((r) => r.data), +}; diff --git a/paperless-frontend/src/auth/oidc.ts b/paperless-frontend/src/auth/oidc.ts index ad55866..a5de680 100644 --- a/paperless-frontend/src/auth/oidc.ts +++ b/paperless-frontend/src/auth/oidc.ts @@ -34,9 +34,36 @@ export async function getUser(): Promise { return userManager.getUser(); } +// Laufender Silent-Renew-Vorgang – mehrere gleichzeitige Aufrufe teilen sich +// denselben Refresh (Dedup), damit nicht parallele signinSilent-Aufrufe mit +// einem rotierenden Refresh-Token kollidieren (invalid_grant). +let renewPromise: Promise | null = null; + +/** + * Erneuert das Token still über den Refresh-Token (signinSilent). Schlägt die + * Erneuerung fehl, wird null geliefert (statt zu werfen). Gleichzeitige Aufrufe + * werden dedupliziert. + */ +export function renewToken(): Promise { + if (!renewPromise) { + renewPromise = userManager.signinSilent().catch((err: unknown) => { + console.error('OIDC: signinSilent fehlgeschlagen', err); + return null; + }); + void renewPromise.finally(() => { + renewPromise = null; + }); + } + return renewPromise; +} + export async function getAccessToken(): Promise { const user = await getUser(); - return user?.access_token ?? null; + if (!user) return null; // nicht eingeloggt + if (!user.expired) return user.access_token ?? null; + // Token ist abgelaufen → vor dem Senden still erneuern. + const renewed = await renewToken(); + return renewed?.access_token ?? null; } export { userManager }; diff --git a/paperless-frontend/src/auth/permissions.ts b/paperless-frontend/src/auth/permissions.ts index ed90aa4..518f0e6 100644 --- a/paperless-frontend/src/auth/permissions.ts +++ b/paperless-frontend/src/auth/permissions.ts @@ -6,6 +6,7 @@ export const Permission = { VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', VIEW_FREIGABE: 'VIEW_FREIGABE', + VIEW_ZAHLUNG: 'VIEW_ZAHLUNG', } as const; export type Permission = typeof Permission[keyof typeof Permission]; @@ -26,6 +27,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.VIEW_FREIGABE); + permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } @@ -44,6 +46,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per if (groups.includes('PM_Freigabe')) { permissions.add(Permission.VIEW_FREIGABE); } + if (groups.includes('PM_Zahlung')) { + permissions.add(Permission.VIEW_ZAHLUNG); + } return Array.from(permissions); } diff --git a/paperless-frontend/src/components/DocumentEditModal.tsx b/paperless-frontend/src/components/DocumentEditModal.tsx index 433c24d..dde6890 100644 --- a/paperless-frontend/src/components/DocumentEditModal.tsx +++ b/paperless-frontend/src/components/DocumentEditModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd'; -import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons'; +import { PlusOutlined, EyeOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { posteingangApi } from '../api/posteingang'; import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang'; @@ -11,6 +11,7 @@ import type { PaperlessDocType, PaperlessCorrespondent, PaperlessTag } from '../ import { getEnv } from '../utils/env'; import { AuthIframe, openAuthUrl } from '../utils/auth-resource'; import DocumentSearchModal from './DocumentSearchModal'; +import { useIsMobile } from '../hooks/useIsMobile'; const { Option } = Select; @@ -26,6 +27,7 @@ interface Props { export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) { const [form] = Form.useForm(); + const isMobile = useIsMobile(); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -349,8 +351,8 @@ export default function DocumentEditModal({ documentId, document, open, onClose, title={`Dokument bearbeiten (${document?.title || ''})`} open={open && !kontonummerMissing} onCancel={() => onClose(false)} - width={1400} - style={{ top: 20 }} + width={{ xs: '100vw', md: 900, xl: 1400 }} + style={isMobile ? {} : { top: 20 }} footer={ hasNextDocument ? [ , @@ -363,8 +365,12 @@ export default function DocumentEditModal({ documentId, document, open, onClose, } > - - + +
@@ -556,10 +562,20 @@ export default function DocumentEditModal({ documentId, document, open, onClose, })} - + + {isMobile && ( + + )} diff --git a/paperless-frontend/src/components/DocumentSearchModal.tsx b/paperless-frontend/src/components/DocumentSearchModal.tsx index 3a5573b..57d804e 100644 --- a/paperless-frontend/src/components/DocumentSearchModal.tsx +++ b/paperless-frontend/src/components/DocumentSearchModal.tsx @@ -57,7 +57,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props) open={open} onCancel={onCancel} footer={null} - width={800} + width={{ xs: '100vw', md: 800 }} style={{ top: 50 }} >
@@ -100,7 +100,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props) description={ ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'} - Erstellt: {dayjs(doc.created_date).format('DD.MM.YYYY')} + Erstellt: {dayjs(doc.created).format('DD.MM.YYYY')} } /> diff --git a/paperless-frontend/src/components/MailImportWizard.tsx b/paperless-frontend/src/components/MailImportWizard.tsx index e9465d8..10b4644 100644 --- a/paperless-frontend/src/components/MailImportWizard.tsx +++ b/paperless-frontend/src/components/MailImportWizard.tsx @@ -538,8 +538,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a {toProcess.map(item => (
{item.fileName} - - + + {/* Eingangsdatum */}
Eingangsdatum: @@ -626,7 +626,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
- + {main.fileName} - - - + + + Eingangsdatum: {datum?.format('DD.MM.YYYY') ?? '—'} @@ -690,7 +690,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a )} {showPrint && ( - + @@ -735,7 +735,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a title="Paperless Import-Wizard" open={visible} onCancel={onClose} - width={1000} + width={{ xs: '100vw', md: 900, lg: 1000 }} footer={ importSuccess ? ( diff --git a/paperless-frontend/src/components/MobileCardList.tsx b/paperless-frontend/src/components/MobileCardList.tsx new file mode 100644 index 0000000..1b55de3 --- /dev/null +++ b/paperless-frontend/src/components/MobileCardList.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; +import { List, Card } from 'antd'; +import type { TablePaginationConfig } from 'antd'; + +interface MobileCardListProps { + dataSource: T[]; + rowKey: keyof T | ((record: T) => React.Key); + loading?: boolean; + /** Gleiche Pagination-Config wie bei — wird durchgereicht (mobil kompakt). */ + pagination?: TablePaginationConfig | false; + /** Seitenspezifischer Karteninhalt für einen Datensatz. */ + renderCard: (record: T) => ReactNode; + onCardClick?: (record: T) => void; + emptyText?: string; +} + +/** + * Mobile Karten-Ansicht als Ersatz für breite Tabellen: + * eine Karte pro Datensatz, Pagination wie bei der Tabelle. + */ +export default function MobileCardList({ + dataSource, + rowKey, + loading, + pagination, + renderCard, + onCardClick, + emptyText = 'Keine Einträge vorhanden', +}: MobileCardListProps) { + const getKey = (record: T): React.Key => + typeof rowKey === 'function' ? rowKey(record) : (record[rowKey] as React.Key); + + return ( + ( + + onCardClick(record) : undefined} + > + {renderCard(record)} + + + )} + /> + ); +} diff --git a/paperless-frontend/src/hooks/useIsMobile.ts b/paperless-frontend/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..e308c26 --- /dev/null +++ b/paperless-frontend/src/hooks/useIsMobile.ts @@ -0,0 +1,11 @@ +import { Grid } from 'antd'; + +/** + * Zentraler Breakpoint-Hook: mobil = Viewport unter `md` (768px). + * Beim allerersten Render liefert useBreakpoint() noch keine Werte — + * dann Desktop annehmen, um ein Layout-Flackern zu vermeiden. + */ +export function useIsMobile(): boolean { + const screens = Grid.useBreakpoint(); + return screens.md === undefined ? false : !screens.md; +} diff --git a/paperless-frontend/src/index.css b/paperless-frontend/src/index.css index 4ab9339..19d4fa7 100644 --- a/paperless-frontend/src/index.css +++ b/paperless-frontend/src/index.css @@ -139,3 +139,29 @@ body { .ant-picker-input > input { font-size: 14px !important; } + +/* ── Responsive / Mobile ─────────────────────────────────────── */ + +html { + -webkit-text-size-adjust: 100%; +} + +@media (max-width: 767px) { + .ant-modal { + top: 8px; + } + + /* Modal-Inhalte scrollbar statt abgeschnitten (v. a. Settings-Dialoge) */ + .ant-modal .ant-modal-body { + max-height: calc(100dvh - 160px); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + + /* Sicherheitsnetz: Tabellen ohne Karten-Ansicht seitlich scrollbar */ + .ant-table-content, + .ant-table-body { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} diff --git a/paperless-frontend/src/layouts/AppLayout.tsx b/paperless-frontend/src/layouts/AppLayout.tsx index 2cfc2c7..f021793 100644 --- a/paperless-frontend/src/layouts/AppLayout.tsx +++ b/paperless-frontend/src/layouts/AppLayout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge } from 'antd'; +import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge, Drawer, Button } from 'antd'; import { InboxOutlined, FileTextOutlined, @@ -14,9 +14,12 @@ import { AppstoreOutlined, GlobalOutlined, CheckCircleOutlined, + EuroOutlined, + MenuOutlined, } from '@ant-design/icons'; import { useAuth } from '../auth/AuthContext'; import { useTheme } from '../theme/ThemeContext'; +import { useIsMobile } from '../hooks/useIsMobile'; import { Permission } from '../auth/permissions'; import { statsApi, type StatsCounts } from '../api/stats'; @@ -40,22 +43,28 @@ const allMenuItems: MenuItemDef[] = [ { key: '/mailpostfach', icon: , label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' }, { key: 'agrarmonitor', icon: , label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' }, { key: '/freigabe', icon: , label: 'Freigabe', permission: Permission.VIEW_FREIGABE }, + { key: '/zahlung', icon: , label: 'Zahlung', permission: Permission.VIEW_ZAHLUNG }, { key: '/settings', icon: , label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS }, ]; export default function AppLayout() { const [collapsed, setCollapsed] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); const navigate = useNavigate(); const location = useLocation(); const { user, logout, hasPermission, isAuthenticated } = useAuth(); const { token: themeToken } = theme.useToken(); const { isDark, toggleTheme } = useTheme(); - + const isMobile = useIsMobile(); + const [counts, setCounts] = useState(null); + // Im Drawer (mobil) ist die Navigation nie eingeklappt + const effectiveCollapsed = isMobile ? false : collapsed; + useEffect(() => { if (!isAuthenticated) return; - + const fetchCounts = async () => { try { const data = await statsApi.getCounts(); @@ -64,10 +73,10 @@ export default function AppLayout() { console.error('Fehler beim Abrufen der Zählerstände:', err); } }; - + fetchCounts(); const interval = setInterval(fetchCounts, 30000); // 30 Sekunden Polling - + return () => clearInterval(interval); }, [isAuthenticated, location.pathname]); // Update after navigation or auth change @@ -78,9 +87,9 @@ export default function AppLayout() { label: (
{item.label} - {item.countKey && counts && counts[item.countKey] > 0 && !collapsed && ( - 0 && !effectiveCollapsed && ( + void) => ( + <> + {/* Logo / Collapse-Toggle */} + + + {/* Navigation Menu */} + { + const item = allMenuItems.find((i) => i.key === key); + if (item?.externalUrl) { + window.open(item.externalUrl, '_blank', 'noopener,noreferrer'); + } else { + navigate(key); + } + onNavigate?.(); + }} + style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }} + /> + + {/* Bottom Section: User + Theme Toggle */} +
+ {/* Theme Toggle */} + + + + + {/* User Menu */} + , + label: 'Benutzereinstellungen', + onClick: () => { + navigate('/user-settings'); + onNavigate?.(); + }, + }, + { + key: 'logout', + icon: , + label: 'Abmelden', + onClick: () => logout(), + }, + ], + }} + placement="topRight" + trigger={['click']} + > +
(e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')} + onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + > + } /> + {!isCollapsed && ( + + {user?.profile?.name || 'Benutzer'} + + )} +
+
+
+ + ); return ( - - {/* Logo / Collapse-Toggle */} - + {isMobile ? ( + <> + {/* Mobile Top-Bar mit Hamburger */} +
+
- {/* Navigation Menu */} - setDrawerOpen(false)} + closable={false} + styles={{ + body: { + padding: 0, + position: 'relative', + background: isDark ? '#001529' : '#f0f2f7', + }, + }} + > + {renderSidebarContent(false, () => setDrawerOpen(false))} + + + ) : ( + { - const item = allMenuItems.find((i) => i.key === key); - if (item?.externalUrl) { - window.open(item.externalUrl, '_blank', 'noopener,noreferrer'); - } else { - navigate(key); - } - }} - style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }} - /> - - {/* Bottom Section: User + Theme Toggle */} -
- {/* Theme Toggle */} - - - + {renderSidebarContent(collapsed)} + + )} - {/* User Menu */} - , - label: 'Benutzereinstellungen', - onClick: () => navigate('/user-settings'), - }, - { - key: 'logout', - icon: , - label: 'Abmelden', - onClick: () => logout(), - }, - ], - }} - placement="topRight" - trigger={['click']} - > -
(e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} - > - } /> - {!collapsed && ( - - {user?.profile?.name || 'Benutzer'} - - )} -
-
-
-
- - - + + diff --git a/paperless-frontend/src/pages/FreigabePage.tsx b/paperless-frontend/src/pages/FreigabePage.tsx index d6c1b18..78275be 100644 --- a/paperless-frontend/src/pages/FreigabePage.tsx +++ b/paperless-frontend/src/pages/FreigabePage.tsx @@ -7,11 +7,14 @@ import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe'; import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless'; +import { useIsMobile } from '../hooks/useIsMobile'; +import MobileCardList from '../components/MobileCardList'; -const { Title } = Typography; +const { Title, Text } = Typography; const FREIGABE_FIELD_ID = 15; export default function FreigabePage() { + const isMobile = useIsMobile(); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); @@ -119,7 +122,7 @@ export default function FreigabePage() { }, { title: 'Erstellt', - dataIndex: 'created_date', + dataIndex: 'created', key: 'created', width: 110, render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—', @@ -153,44 +156,92 @@ export default function FreigabePage() { }, ]; + const paginationConfig = { + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: ['25', '50', '100'], + onChange: (p: number, ps: number) => { + setPage(p); + setPageSize(ps); + }, + showTotal: (t: number) => `${t} Belege`, + }; + return ( <> Freigabe - - { + onChange={(v) => { setPage(1); - setNurNichtFreigegeben(e.target.value); + setNurNichtFreigegeben(v); }} - optionType="button" - buttonStyle="solid" - > - Nicht freigegeben - Alle - - + options={[ + { value: true, label: 'Nicht freigegeben' }, + { value: false, label: 'Alle' }, + ]} + /> + ) : ( + + { + setPage(1); + setNurNichtFreigegeben(e.target.value); + }} + optionType="button" + buttonStyle="solid" + > + Nicht freigegeben + Alle + + + )} - - dataSource={data} - columns={columns} - rowKey="id" - loading={loading} - size="small" - pagination={{ - current: page, - pageSize, - total, - showSizeChanger: true, - pageSizeOptions: ['25', '50', '100'], - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, - showTotal: (t) => `${t} Belege`, - }} - /> + {isMobile ? ( + + dataSource={data} + rowKey="id" + loading={loading} + pagination={paginationConfig} + emptyText="Keine Belege vorhanden" + renderCard={(doc) => ( +
+ {doc.title || '—'} + Dokumenttyp: {getDocTypeName(doc.document_type)} + Absender: {getCorrespondentName(doc.correspondent)} +
+ + Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'} + + {getFreigabeValue(doc)} +
+ +
+ )} + /> + ) : ( + + dataSource={data} + columns={columns} + rowKey="id" + loading={loading} + size="small" + pagination={paginationConfig} + /> + )} (null); const [inboxUrl, setInboxUrl] = useState(null); const [loading, setLoading] = useState(false); @@ -165,8 +167,8 @@ function CompareModal({ , ]} > -
-
+
+
Original (Paperless) @@ -180,7 +182,7 @@ function CompareModal({ )}
-
+
Aktueller Abschnitt (Inbox) @@ -858,6 +860,7 @@ function SendEmailDialog({ open, fileId, fileName, documents, thumbUrls, onClose export default function InboxDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const isMobile = useIsMobile(); const [file, setFile] = useState(null); const [loading, setLoading] = useState(true); const [thumbUrls, setThumbUrls] = useState>(new Map()); @@ -1211,13 +1214,13 @@ export default function InboxDetailPage() { })(); return ( -
-
- +
+
+ - + <Title level={isMobile ? 5 : 3} style={{ margin: 0, maxWidth: isMobile ? 180 : undefined }} ellipsis={{ tooltip: file.name }}> {file.name} @@ -1399,12 +1402,13 @@ export default function InboxDetailPage() { >
{sidebarPages.map((n, idx) => { @@ -1428,7 +1432,7 @@ export default function InboxDetailPage() {
) : ( diff --git a/paperless-frontend/src/pages/InboxPage.tsx b/paperless-frontend/src/pages/InboxPage.tsx index 943c1ca..b8372a1 100644 --- a/paperless-frontend/src/pages/InboxPage.tsx +++ b/paperless-frontend/src/pages/InboxPage.tsx @@ -37,6 +37,8 @@ import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox'; import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates'; import { labelPrintAgentApi } from '../api/labelPrintAgent'; import { userSettingsApi } from '../api/userSettings'; +import { useIsMobile } from '../hooks/useIsMobile'; +import MobileCardList from '../components/MobileCardList'; const { Title } = Typography; @@ -51,6 +53,18 @@ function formatDate(iso: string): string { }); } +function renderSourceTag(src: InboxFile['source']): ReactNode { + return src === 'user' ? ( + } color="purple"> + Persönlich + + ) : ( + } color="blue"> + Gemeinsam + + ); +} + function renderBarcodes(barcodes: InboxBarcode[]): ReactNode { if (!barcodes || barcodes.length === 0) { return ; @@ -137,6 +151,7 @@ function buildInitialFieldValues(template: BarcodeTemplate | null): Record([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); @@ -261,6 +276,50 @@ export default function InboxPage() { search ? f.name.toLowerCase().includes(search.toLowerCase()) : true, ); + // Karten-Ansicht sortiert wie die Tabelle (neueste zuerst) + const sortedForMobile = [...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + const renderActions = (record: InboxFile, direction: 'column' | 'row' = 'column') => ( +
+ + + + handleDelete(record.id)} + > + + + + + +
+ ); + const columns: ColumnsType = [ { title: 'Dateiname', @@ -283,16 +342,7 @@ export default function InboxPage() { { text: 'Persönlich', value: 'user' }, ], onFilter: (value, record) => record.source === value, - render: (src: InboxFile['source']) => - src === 'user' ? ( - } color="purple"> - Persönlich - - ) : ( - } color="blue"> - Gemeinsam - - ), + render: (src: InboxFile['source']) => renderSourceTag(src), }, { title: 'QR-Code / Vorlage', @@ -321,46 +371,7 @@ export default function InboxPage() { title: 'Aktionen', key: 'actions', width: 140, - render: (_, record) => ( -
- - - - handleDelete(record.id)} - > - - - - - -
- ), + render: (_, record) => renderActions(record), }, ]; @@ -371,6 +382,8 @@ export default function InboxPage() { display: 'flex', justifyContent: 'space-between', alignItems: 'center', + flexWrap: 'wrap', + gap: 12, marginBottom: 16, }} > @@ -383,11 +396,11 @@ export default function InboxPage() { Scan-Ordner.
- + } placeholder="Suchen …" - style={{ width: 260 }} + style={{ width: isMobile ? '100%' : 260 }} value={search} onChange={(e) => setSearch(e.target.value)} allowClear @@ -478,20 +491,48 @@ export default function InboxPage() { - - + {isMobile ? ( + + dataSource={sortedForMobile} rowKey="id" - columns={columns} - dataSource={filtered} loading={loading} pagination={{ pageSize: 25, - showSizeChanger: true, showTotal: (t) => `${t} Dateien`, }} - locale={{ emptyText: 'Keine Dateien vorhanden' }} + emptyText="Keine Dateien vorhanden" + renderCard={(record) => ( +
+ + {record.name} + +
+ {renderSourceTag(record.source)} + + {record.pageCount > 0 ? `${record.pageCount} Seiten` : '—'} · {formatDate(record.createdAt)} + +
+ {renderBarcodes(record.barcodes)} + {renderActions(record, 'row')} +
+ )} /> -
+ ) : ( + + + rowKey="id" + columns={columns} + dataSource={filtered} + loading={loading} + pagination={{ + pageSize: 25, + showSizeChanger: true, + showTotal: (t) => `${t} Dateien`, + }} + locale={{ emptyText: 'Keine Dateien vorhanden' }} + /> + + )}
); } diff --git a/paperless-frontend/src/pages/MailDetailPage.tsx b/paperless-frontend/src/pages/MailDetailPage.tsx index d821e3b..e42ee07 100644 --- a/paperless-frontend/src/pages/MailDetailPage.tsx +++ b/paperless-frontend/src/pages/MailDetailPage.tsx @@ -10,12 +10,14 @@ import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails'; import { emailImportApi } from '../api/email-import'; import { getEnv } from '../utils/env'; import MailImportWizard from '../components/MailImportWizard'; +import { useIsMobile } from '../hooks/useIsMobile'; const { Title, Text } = Typography; export default function MailDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const isMobile = useIsMobile(); const [email, setEmail] = useState(null); const [attachments, setAttachments] = useState([]); const [selected, setSelected] = useState(null); @@ -136,16 +138,16 @@ export default function MailDetailPage() { return (
-
- +
+ - {email.Subject} + {email.Subject} {hasErechnung && eRechnung} - - + +
-
+
{/* Linke Seite: E-Mail-Inhalt */}
Von: {email.SenderAddress}
@@ -208,7 +224,17 @@ export default function MailDetailPage() { {/* Rechte Seite: Anhänge + Vorschau */} - +
columns={columns} @@ -224,7 +250,7 @@ export default function MailDetailPage() { locale={{ emptyText: }} />
-
+
{previewLoading ? (
diff --git a/paperless-frontend/src/pages/MailpostfachPage.tsx b/paperless-frontend/src/pages/MailpostfachPage.tsx index 5eec79f..07c0062 100644 --- a/paperless-frontend/src/pages/MailpostfachPage.tsx +++ b/paperless-frontend/src/pages/MailpostfachPage.tsx @@ -7,11 +7,22 @@ import dayjs from 'dayjs'; import { emailsApi, type EmailItem } from '../api/emails'; import { useAuth } from '../auth/AuthContext'; import { Permission } from '../auth/permissions'; +import { useIsMobile } from '../hooks/useIsMobile'; +import MobileCardList from '../components/MobileCardList'; -const { Title } = Typography; +const { Title, Text } = Typography; + +function renderStatusTag(s: number) { + if (s === 0) return Neu; + if (s === 1) return Verarbeitet; + if (s === 2) return Fehler; + if (s === 3) return Ignoriert; + return {s}; +} export default function MailpostfachPage() { const navigate = useNavigate(); + const isMobile = useIsMobile(); const [emails, setEmails] = useState([]); const [loading, setLoading] = useState(false); const [fetching, setFetching] = useState(false); @@ -84,13 +95,7 @@ export default function MailpostfachPage() { dataIndex: 'Status', key: 'Status', width: 110, - render: (s: number) => { - if (s === 0) return Neu; - if (s === 1) return Verarbeitet; - if (s === 2) return Fehler; - if (s === 3) return Ignoriert; - return {s}; - }, + render: renderStatusTag, filters: [ { text: 'Neu', value: 0 }, { text: 'Verarbeitet', value: 1 }, @@ -116,9 +121,9 @@ export default function MailpostfachPage() { return (
-
+
Mailpostfach - +
`${t} Dokumente` }} - locale={{ emptyText: 'Keine Einträge vorhanden' }} - /> + {isMobile ? ( + + dataSource={data} + rowKey="id" + loading={loading} + pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }} + renderCard={(record) => ( +
+
+ +
+ {record.title || '—'} + {renderContentTags(record)} + + Eingangsdatum: {record.created ? dayjs(record.created).format('DD.MM.YYYY') : '-'} + + + Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')} + +
+
+ +
+ )} + /> + ) : ( +
`${t} Dokumente` }} + locale={{ emptyText: 'Keine Einträge vorhanden' }} + /> + )} ([]); const [loading, setLoading] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); @@ -64,6 +67,11 @@ export default function PosteingangPage() { /> ); + const getEingangsdatum = (record: PosteingangDocument) => { + const cf = record.customFields?.find((f) => f.field === 9); + return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-'; + }; + const columns = [ { title: 'Vorschau', @@ -88,10 +96,7 @@ export default function PosteingangPage() { { title: 'Eingangsdatum', key: 'eingangsdatum', - render: (_: any, record: PosteingangDocument) => { - const cf = record.customFields?.find((f) => f.field === 9); - return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-'; - } + render: (_: any, record: PosteingangDocument) => getEingangsdatum(record), }, { title: 'Importiert am', @@ -121,14 +126,46 @@ export default function PosteingangPage() { -
`${t} Dokumente` }} - locale={{ emptyText: 'Keine Einträge vorhanden' }} - /> + {isMobile ? ( + + dataSource={data} + rowKey="id" + loading={loading} + pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }} + renderCard={(record) => ( +
+
+ +
+ {record.title || '—'} + + Eingangsdatum: {getEingangsdatum(record)} + + + Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')} + +
+
+ +
+ )} + /> + ) : ( +
`${t} Dokumente` }} + locale={{ emptyText: 'Keine Einträge vorhanden' }} + /> + )} } onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}> Zuordnung hinzufügen -
+
Betriebe — Agrarmonitor-Zuordnung @@ -334,6 +335,7 @@ function UserClientsTab() { rowKey="Id" size="small" pagination={false} + scroll={{ x: 'max-content' }} /> setModalOpen(false)}> @@ -598,13 +600,14 @@ function DocTypesTab() { return ( <> -
} onClick={openNew} style={{ marginBottom: 16 }}> Regel hinzufügen -
+
} onClick={openNew} style={{ marginBottom: 16 }}> Export-Ziel hinzufügen -
+
setEditing(null)}>
@@ -1246,6 +1249,7 @@ function PostprocessingLogsTab() { loading={loading} rowKey="Id" size="small" + scroll={{ x: 'max-content' }} pagination={{ current: page, total, @@ -1351,7 +1355,7 @@ function ApiKeysTab() { -
+
-
Weiterverarbeitungs-Aktionen - rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" /> + rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" scroll={{ x: 'max-content' }} /> + + + + + + @@ -2748,6 +2768,98 @@ function SteuertagsTab() { // Settings Page // ═══════════════════════════════════════════════════════════════════ +// ═══════════════════════════════════════════════════════════════════ +// Webhook-Status Tab +// ═══════════════════════════════════════════════════════════════════ + +function WebhookStatusTab() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await webhookApi.getStatus(); + setStatus(data); + } catch (err) { + console.error('Error loading webhook status:', err); + message.error('Webhook-Status konnte nicht geladen werden'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const renderStatusBadge = (s: string) => { + switch (s) { + case 'queued': return ; + case 'processed': return ; + case 'error': return ; + case 'bad-request': return ; + default: return ; + } + }; + + const lastCall = status?.lastCall ?? null; + const queueSize = status?.queueSize ?? 0; + + return ( + <> +
+ Webhook-Status + + Paperless-NGX ruft nach dem Bearbeiten eines Dokuments den Webhook{' '} + /api/webhook/paperless auf + (per API-Key authentifiziert). Die ID wird in eine Warteschlange + gelegt und von einem separaten Prozess nacheinander verarbeitet. + Hier siehst du den zuletzt festgehaltenen Vorgang. Eine vollständige + Historie steht in den Backend-Logs. + + + + 0 ? 'processing' : 'default'}> + Warteschlange: {queueSize} + + +
+ + + {lastCall ? ( + + + {dayjs(lastCall.at).format('DD.MM.YYYY HH:mm:ss')} + + + {lastCall.documentId ?? '—'} + + + {lastCall.action ? {lastCall.action} : '—'} + + + {renderStatusBadge(lastCall.status)} + {lastCall.reason ? ( + ({lastCall.reason}) + ) : null} + + {lastCall.message ? ( + + {lastCall.message} + + ) : null} + + ) : ( + + Noch kein Webhook-Aufruf erfolgt. + + )} + + + ); +} + export default function SettingsPage() { return (
@@ -2805,6 +2917,11 @@ export default function SettingsPage() { label: API-Keys, children: , }, + { + key: 'webhook', + label: Webhook, + children: , + }, { key: 'agrarmonitor', label: Agrarmonitor, diff --git a/paperless-frontend/src/pages/TaskLogPage.tsx b/paperless-frontend/src/pages/TaskLogPage.tsx index 3e50a7f..8553079 100644 --- a/paperless-frontend/src/pages/TaskLogPage.tsx +++ b/paperless-frontend/src/pages/TaskLogPage.tsx @@ -1,9 +1,13 @@ import { useEffect, useState } from 'react'; -import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider } from 'antd'; +import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider, Typography } from 'antd'; import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { tasksApi } from '../api/tasks'; import type { Task } from '../api/tasks'; +import { useIsMobile } from '../hooks/useIsMobile'; +import MobileCardList from '../components/MobileCardList'; + +const { Text } = Typography; function statusTag(fertig: number | null) { if (fertig === 1) return Fertig; @@ -12,6 +16,7 @@ function statusTag(fertig: number | null) { } export default function TaskLogPage() { + const isMobile = useIsMobile(); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [deleting, setDeleting] = useState(false); @@ -155,14 +160,55 @@ export default function TaskLogPage() {
-
+ {isMobile ? ( + + dataSource={data} + rowKey="TaskId" + loading={loading} + pagination={{ pageSize: 20 }} + renderCard={(record) => ( +
+
+ {record.InterneBelegnummer || '—'} + {statusTag(record.Fertig)} +
+ + {record.TaskId.slice(0, 8)}… + + Lieferant: {record.Lieferant || '-'} + + Belegdatum: {record.Belegdatum ? dayjs(record.Belegdatum).format('DD.MM.YYYY') : '-'} + {' · '} + Eingang: {record.Eingangsdatum ? dayjs(record.Eingangsdatum).format('DD.MM.YYYY') : '-'} + + + Paperless-Dok.-ID: {record.PaperlessDocumentID ?? '-'} + + handleDeleteOne(record.TaskId)} + okText="Löschen" + cancelText="Abbrechen" + okButtonProps={{ danger: true }} + > + + +
+ )} + /> + ) : ( +
+ )} ); diff --git a/paperless-frontend/src/pages/ZahlungPage.tsx b/paperless-frontend/src/pages/ZahlungPage.tsx new file mode 100644 index 0000000..077a526 --- /dev/null +++ b/paperless-frontend/src/pages/ZahlungPage.tsx @@ -0,0 +1,308 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Table, Typography, Tag, Button, Modal, Select, message, Space, Radio, Tooltip } from 'antd'; +import { EuroOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung'; +import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless'; +import { useIsMobile } from '../hooks/useIsMobile'; +import MobileCardList from '../components/MobileCardList'; + +const { Title, Text } = Typography; +const FREIGABE_FIELD_ID = 15; +const ZAHLUNG_FIELD_ID = 16; +const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; + +const FILTER_OPTIONS: { value: ZahlungFilter; label: string }[] = [ + { value: 'ausstehend', label: 'Freigegeben, noch nicht bezahlt' }, + { value: 'freigegeben', label: 'Alle freigegebenen' }, + { value: 'alle', label: 'Alle' }, +]; + +export default function ZahlungPage() { + const isMobile = useIsMobile(); + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [filter, setFilter] = useState('ausstehend'); + + const [docTypes, setDocTypes] = useState([]); + const [correspondents, setCorrespondents] = useState([]); + const [zahlungOptions, setZahlungOptions] = useState([]); + + const [selectedDoc, setSelectedDoc] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + Promise.all([ + paperlessApi.getDocumentTypes(), + paperlessApi.getCorrespondents(), + zahlungApi.getOptions(), + ]).then(([dts, corrs, opts]) => { + setDocTypes(dts); + setCorrespondents(corrs); + setZahlungOptions(opts); + }); + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const result = await zahlungApi.getDocuments(page, pageSize, filter); + setData(result.results ?? []); + setTotal(result.count ?? 0); + } catch { + message.error('Fehler beim Laden der Belege'); + } finally { + setLoading(false); + } + }, [page, pageSize, filter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const getDocTypeName = (id: number | null) => { + if (!id) return '—'; + return docTypes.find((d) => d.id === id)?.name ?? String(id); + }; + + const getCorrespondentName = (id: number | null) => { + if (!id) return '—'; + return correspondents.find((c) => c.id === id)?.name ?? String(id); + }; + + const toCfString = (value: any): string | null => { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'object') return String(value?.id ?? value?.value ?? value?.label ?? '') || null; + return String(value); + }; + + const getCfValue = (doc: ZahlungDocument, fieldId: number) => { + const cf = doc.custom_fields?.find((f) => f.field === fieldId); + return toCfString(cf?.value); + }; + + const renderFreigabeTag = (doc: ZahlungDocument) => { + const val = getCfValue(doc, FREIGABE_FIELD_ID); + if (!val) return Nicht gesetzt; + if (val === FREIGABE_WERT_FREIGEGEBEN) return Freigegeben; + return {val}; + }; + + const renderZahlungTag = (doc: ZahlungDocument) => { + const val = getCfValue(doc, ZAHLUNG_FIELD_ID); + if (!val) return Nicht gesetzt; + const opt = zahlungOptions.find((o) => o.id === val); + return {opt?.label ?? val}; + }; + + const istFreigegeben = (doc: ZahlungDocument) => + getCfValue(doc, FREIGABE_FIELD_ID) === FREIGABE_WERT_FREIGEGEBEN; + + const openModal = (doc: ZahlungDocument) => { + setSelectedDoc(doc); + setSelectedValue(getCfValue(doc, ZAHLUNG_FIELD_ID)); + setModalOpen(true); + }; + + const handleZahlung = async () => { + if (!selectedDoc) return; + setSaving(true); + try { + await zahlungApi.setZahlung(selectedDoc.id, selectedValue); + message.success('Zahlung gesetzt'); + setModalOpen(false); + setSelectedDoc(null); + fetchData(); + } catch { + message.error('Fehler beim Speichern der Zahlung'); + } finally { + setSaving(false); + } + }; + + const columns: ColumnsType = [ + { + title: 'Dokumenttyp', + dataIndex: 'document_type', + key: 'doctype', + render: getDocTypeName, + }, + { + title: 'Titel', + dataIndex: 'title', + key: 'title', + ellipsis: true, + }, + { + title: 'Erstellt', + dataIndex: 'created', + key: 'created', + width: 110, + render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—', + }, + { + title: 'Absender', + dataIndex: 'correspondent', + key: 'correspondent', + render: getCorrespondentName, + }, + { + title: 'Freigabe', + key: 'freigabe', + width: 130, + render: (_, doc) => renderFreigabeTag(doc), + }, + { + title: 'Zahlung', + key: 'zahlung', + width: 130, + render: (_, doc) => renderZahlungTag(doc), + }, + { + title: '', + key: 'action', + width: 150, + render: (_, doc) => { + const freigegeben = istFreigegeben(doc); + return ( + + + + ); + }, + }, + ]; + + const paginationConfig = { + current: page, + pageSize, + total, + showSizeChanger: true, + pageSizeOptions: ['25', '50', '100'], + onChange: (p: number, ps: number) => { + setPage(p); + setPageSize(ps); + }, + showTotal: (t: number) => `${t} Belege`, + }; + + return ( + <> + Zahlung + + {isMobile ? ( + setSelectedValue(v ?? null)} + options={zahlungOptions.map((o) => ({ value: o.id, label: o.label }))} + /> + + + ); +}