diff --git a/paperless-backend/src/daily-digest/daily-digest.controller.ts b/paperless-backend/src/daily-digest/daily-digest.controller.ts index 684c8fe..4dc7a64 100644 --- a/paperless-backend/src/daily-digest/daily-digest.controller.ts +++ b/paperless-backend/src/daily-digest/daily-digest.controller.ts @@ -8,12 +8,12 @@ export class DailyDigestController { @Post('send-now') @HttpCode(200) async sendNow(@Request() req: any) { - const { userId, email, preferredUsername } = req.user; + const { userId, email, preferredUsername, groups } = req.user; if (!email) { return { ok: false, error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.' }; } try { - await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername); + await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername, groups); return { ok: true }; } catch (err: any) { return { ok: false, error: err.message }; diff --git a/paperless-backend/src/daily-digest/daily-digest.service.ts b/paperless-backend/src/daily-digest/daily-digest.service.ts index 82672ad..e9eced1 100644 --- a/paperless-backend/src/daily-digest/daily-digest.service.ts +++ b/paperless-backend/src/daily-digest/daily-digest.service.ts @@ -4,6 +4,30 @@ import { Cron } from '@nestjs/schedule'; import { StatsService, DashboardCounts } from '../stats/stats.service'; import { UserSettingsService } from '../user-settings/user-settings.service'; import { MailService } from '../postprocessing/mail.service'; +import { mapGroupsToPermissions, Permission } from '../auth/permissions.enum'; + +interface DigestTile { + key: keyof DashboardCounts; + title: string; + description: string; + icon: string; + accent: string; + accentSoft: string; + permission: Permission; +} + +const DIGEST_TILES: DigestTile[] = [ + { key: 'inbox', title: 'Eingangsbox', description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.', icon: '📥', accent: '#1677ff', accentSoft: '#e6f0ff', permission: Permission.VIEW_SCANNER }, + { key: 'posteingang', title: 'Posteingang', description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.', icon: '📄', accent: '#13c2c2', accentSoft: '#e6fffb', permission: Permission.VIEW_INBOX }, + { key: 'manuell', title: 'Manuell bearbeiten', description: 'Dokumente mit fehlender Erkennung manuell ergänzen.', icon: '✏️', accent: '#fa8c16', accentSoft: '#fff7e6', permission: Permission.PROCESS_MANUALLY }, + { key: 'mailpostfach', title: 'Mailpostfach', description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.', icon: '📬', accent: '#722ed1', accentSoft: '#f9f0ff', permission: Permission.VIEW_MAIL }, + { key: 'agrarmonitor', title: 'In Agrarmonitor', description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.', icon: '🌱', accent: '#52c41a', accentSoft: '#f6ffed', permission: Permission.PROCESS_MANUALLY }, +]; + +function getVisibleTiles(groups: string[] | null | undefined): DigestTile[] { + const permissions = mapGroupsToPermissions(groups ?? []); + return DIGEST_TILES.filter(t => permissions.includes(t.permission)); +} @Injectable() export class DailyDigestService { @@ -21,11 +45,16 @@ export class DailyDigestService { this.agrarmonitorBaseUrl = this.configService.get('AGRARMONITOR_BASE_URL', '').replace(/\/+$/, ''); } - async sendDigestForUser(userId: string, email: string, preferredUsername?: string) { + async sendDigestForUser(userId: string, email: string, preferredUsername?: string, groups?: string[]) { + const visibleTiles = getVisibleTiles(groups); + if (visibleTiles.length === 0) { + this.logger.warn(`Kein Digest für ${email}: keine sichtbaren Kacheln (Gruppen: ${JSON.stringify(groups)})`); + return; + } const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const counts = await this.statsService.getDashboardCounts(preferredUsername); - const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl); - const plainText = buildDigestPlainText(counts, today); + const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles); + const plainText = buildDigestPlainText(counts, today, visibleTiles); await this.mailService.sendMail({ to: email, subject: `Paperless Manager – Tagesübersicht ${today}`, @@ -49,9 +78,14 @@ export class DailyDigestService { for (const sub of subscribers) { try { + const visibleTiles = getVisibleTiles(sub.UserGroups); + if (visibleTiles.length === 0) { + this.logger.warn(`Überspringe Digest für ${sub.UserEmail}: keine sichtbaren Kacheln`); + continue; + } const counts = await this.statsService.getDashboardCounts(sub.UserPreferredUsername ?? undefined); - const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl); - const plainText = buildDigestPlainText(counts, today); + const html = buildDigestHtml(counts, today, this.appUrl, this.agrarmonitorBaseUrl, visibleTiles); + const plainText = buildDigestPlainText(counts, today, visibleTiles); await this.mailService.sendMail({ to: sub.UserEmail!, subject: `Paperless Manager – Tagesübersicht ${today}`, @@ -66,59 +100,17 @@ export class DailyDigestService { } } -function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, agrarmonitorBaseUrl: string): string { - const tiles = [ - { - key: 'inbox' as const, - title: 'Eingangsbox', - description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.', - icon: '📥', - accent: '#1677ff', - accentSoft: '#e6f0ff', - url: appUrl ? `${appUrl}/inbox` : '', - count: counts.inbox, - }, - { - key: 'posteingang' as const, - title: 'Posteingang', - description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.', - icon: '📄', - accent: '#13c2c2', - accentSoft: '#e6fffb', - url: appUrl ? `${appUrl}/posteingang` : '', - count: counts.posteingang, - }, - { - key: 'manuell' as const, - title: 'Manuell bearbeiten', - description: 'Dokumente mit fehlender Erkennung manuell ergänzen.', - icon: '✏️', - accent: '#fa8c16', - accentSoft: '#fff7e6', - url: appUrl ? `${appUrl}/manuell` : '', - count: counts.manuell, - }, - { - key: 'mailpostfach' as const, - title: 'Mailpostfach', - description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.', - icon: '📬', - accent: '#722ed1', - accentSoft: '#f9f0ff', - url: appUrl ? `${appUrl}/mailpostfach` : '', - count: counts.mailpostfach, - }, - { - key: 'agrarmonitor' as const, - title: 'In Agrarmonitor', - description: 'Dokumente im Agrarmonitor-Eingang anzeigen und verwalten.', - icon: '🌱', - accent: '#52c41a', - accentSoft: '#f6ffed', - url: agrarmonitorBaseUrl ? `${agrarmonitorBaseUrl}/dateien/eingang#dateien` : '', - count: counts.agrarmonitor, - }, - ]; +function tileUrl(tile: DigestTile, appUrl: string, agrarmonitorBaseUrl: string): string { + if (tile.key === 'agrarmonitor') return agrarmonitorBaseUrl ? `${agrarmonitorBaseUrl}/dateien/eingang#dateien` : ''; + return appUrl ? `${appUrl}/${tile.key === 'inbox' ? 'inbox' : tile.key}` : ''; +} + +function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, agrarmonitorBaseUrl: string, visibleTiles: DigestTile[]): string { + const tiles = visibleTiles.map(t => ({ + ...t, + url: tileUrl(t, appUrl, agrarmonitorBaseUrl), + count: counts[t.key], + })); const totalOpen = tiles.reduce((sum, t) => sum + t.count, 0); const summaryText = totalOpen > 0 @@ -205,15 +197,12 @@ function buildDigestHtml(counts: DashboardCounts, today: string, appUrl: string, `; } -function buildDigestPlainText(counts: DashboardCounts, today: string): string { +function buildDigestPlainText(counts: DashboardCounts, today: string, visibleTiles: DigestTile[]): string { + const lines = visibleTiles.map(t => ` ${t.title.padEnd(22)} ${counts[t.key]}`).join('\n'); return `Paperless Manager – Tagesübersicht ${today} Offene Vorgänge: - Eingangsbox (Scanner): ${counts.inbox} - Posteingang: ${counts.posteingang} - Manuell bearbeiten: ${counts.manuell} - Mailpostfach: ${counts.mailpostfach} - In Agrarmonitor: ${counts.agrarmonitor} +${lines} Diese E-Mail wird täglich automatisch von Paperless Manager versendet. `; diff --git a/paperless-backend/src/database/entities/user-settings.entity.ts b/paperless-backend/src/database/entities/user-settings.entity.ts index 7e3c94f..3159411 100644 --- a/paperless-backend/src/database/entities/user-settings.entity.ts +++ b/paperless-backend/src/database/entities/user-settings.entity.ts @@ -38,6 +38,9 @@ export class UserSettings { @Column({ type: 'boolean', default: false }) DailyDigestEnabled!: boolean; + @Column({ type: 'json', nullable: true }) + UserGroups!: string[] | null; + @Column({ type: 'varchar', length: 255, nullable: true }) UserEmail!: string | null; diff --git a/paperless-backend/src/email/email-import.controller.ts b/paperless-backend/src/email/email-import.controller.ts index 0a08fcf..3a7cdc0 100644 --- a/paperless-backend/src/email/email-import.controller.ts +++ b/paperless-backend/src/email/email-import.controller.ts @@ -130,6 +130,13 @@ export class EmailImportController { res.sendFile(previewPath); } + // --- Import Job Status --- + @Get('jobs/:jobId/status') + @RequirePermissions(Permission.VIEW_MAIL) + getJobStatus(@Param('jobId') jobId: string) { + return this.importService.getJobStatus(jobId) ?? { message: '', done: false }; + } + // --- Final Import --- @Post('execute') @RequirePermissions(Permission.VIEW_MAIL) diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index 5e3c414..be8c083 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -21,6 +21,16 @@ import * as crypto from 'crypto'; @Injectable() export class EmailImportService { private readonly logger = new Logger(EmailImportService.name); + private readonly importJobs = new Map(); + + private setJobStatus(jobId: string | undefined, message: string, done = false): void { + if (!jobId) return; + this.importJobs.set(jobId, { message, done }); + } + + getJobStatus(jobId: string): { message: string; done: boolean } | null { + return this.importJobs.get(jobId) ?? null; + } constructor( private readonly configService: ConfigService, @@ -328,12 +338,13 @@ export class EmailImportService { // --- Import Logic --- async executeImport(data: { + jobId?: string; attachments: { attachmentId: number; type: 'MAIN' | 'ATTACHMENT' | 'IGNORE'; paperlessCorrespondentId?: number | null; - parentDocumentId?: number | null; // Used if type is ATTACHMENT (should map to a Custom Field theoretically, or just tags. For now, CF if configured, but we pass it) - splitRanges?: { start: number; end: number }[]; // 1-based pages, e.g. [{start: 1, end: 3}, {start: 4, end: 5}] + parentDocumentId?: number | null; + splitRanges?: { start: number; end: number }[]; barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string }; belegnummer?: string; }[]; @@ -341,6 +352,7 @@ export class EmailImportService { }): Promise<{ success: boolean; results: any[] }> { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperless-mail-import-')); const results = []; + this.setJobStatus(data.jobId, 'Dokumente werden vorbereitet...'); try { for (const att of data.attachments) { @@ -417,8 +429,9 @@ export class EmailImportService { }; if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId; + this.setJobStatus(data.jobId, `Lade ${uploadItem.filename} hoch...`); const paperlessTaskId = await this.paperlessService.uploadDocument(uploadItem.path, options); - + // Create background task for enrichment (same logic as Inbox) const backgroundTask = this.taskRepo.create({ TaskId: paperlessTaskId, @@ -437,6 +450,7 @@ export class EmailImportService { // Still poll for Doc ID so we can return it to the frontend for immediate preview let docId = null; for (let i = 0; i < 30; i++) { + this.setJobStatus(data.jobId, `Warte auf Paperless-Verarbeitung... (${i + 1}/30)`); await new Promise(resolve => setTimeout(resolve, 2000)); try { const taskStatus = await this.paperlessService.getTask(paperlessTaskId); @@ -481,10 +495,12 @@ export class EmailImportService { } } + this.setJobStatus(data.jobId, 'Import abgeschlossen', true); return { success: true, results }; } finally { - // Clean up temp dir + // Clean up temp dir and job status await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + if (data.jobId) setTimeout(() => this.importJobs.delete(data.jobId!), 5000); } } } diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts index 153cec5..cc4b90a 100644 --- a/paperless-backend/src/user-settings/user-settings.controller.ts +++ b/paperless-backend/src/user-settings/user-settings.controller.ts @@ -9,12 +9,12 @@ export class UserSettingsController { @Get() async getSettings(@Request() req: any) { - return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername); + return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername, req.user.groups); } @Put() async updateSettings(@Request() req: any, @Body() body: any) { - return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername); + return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername, req.user.groups); } @Get('senders') diff --git a/paperless-backend/src/user-settings/user-settings.service.ts b/paperless-backend/src/user-settings/user-settings.service.ts index fb0b45e..f4f1b66 100644 --- a/paperless-backend/src/user-settings/user-settings.service.ts +++ b/paperless-backend/src/user-settings/user-settings.service.ts @@ -65,14 +65,15 @@ export class UserSettingsService { return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); } - async getSettings(userId: string, email?: string, preferredUsername?: string): Promise { + async getSettings(userId: string, email?: string, preferredUsername?: string, groups?: string[]): Promise { let entity = await this.repo.findOne({ where: { UserId: userId } }); - if (email || preferredUsername) { + if (email || preferredUsername || groups) { if (!entity) { entity = this.repo.create({ UserId: userId }); } if (email) entity.UserEmail = email; if (preferredUsername) entity.UserPreferredUsername = preferredUsername; + if (groups) entity.UserGroups = groups; await this.repo.save(entity); } return this.toDto(entity); @@ -95,6 +96,7 @@ export class UserSettingsService { }, email?: string, preferredUsername?: string, + groups?: string[], ): Promise { let entity = await this.repo.findOne({ where: { UserId: userId } }); if (!entity) { @@ -116,6 +118,7 @@ export class UserSettingsService { if (data.dailyDigestEnabled !== undefined) entity.DailyDigestEnabled = data.dailyDigestEnabled; if (email) entity.UserEmail = email; if (preferredUsername) entity.UserPreferredUsername = preferredUsername; + if (groups) entity.UserGroups = groups; await this.repo.save(entity); return this.toDto(entity); diff --git a/paperless-frontend/src/api/email-import.ts b/paperless-frontend/src/api/email-import.ts index a516820..a486654 100644 --- a/paperless-frontend/src/api/email-import.ts +++ b/paperless-frontend/src/api/email-import.ts @@ -62,10 +62,19 @@ export const emailImportApi = { return res.data.isDuplicate; }, - executeImport: async (emailDate: string, attachments: AttachmentImportData[]): Promise<{ success: boolean; results: any[] }> => { - const res = await api.post('/api/email-import/execute', { emailDate, attachments }); + executeImport: async (emailDate: string, attachments: AttachmentImportData[], jobId?: string): Promise<{ success: boolean; results: any[] }> => { + const res = await api.post('/api/email-import/execute', { emailDate, attachments, jobId }, { timeout: 300_000 }); return res.data; }, + + getJobStatus: async (jobId: string): Promise<{ message: string; done: boolean } | null> => { + try { + const res = await api.get(`/api/email-import/jobs/${jobId}/status`); + return res.data; + } catch { + return null; + } + }, ensurePreviews: async (emailId: number): Promise => { await api.post(`/api/email-import/emails/${emailId}/ensure-previews`); diff --git a/paperless-frontend/src/components/MailImportWizard.tsx b/paperless-frontend/src/components/MailImportWizard.tsx index f4c1606..e9465d8 100644 --- a/paperless-frontend/src/components/MailImportWizard.tsx +++ b/paperless-frontend/src/components/MailImportWizard.tsx @@ -44,6 +44,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a // Step 3 specific state const [importSuccess, setImportSuccess] = useState(false); + const [importStatus, setImportStatus] = useState(''); useEffect(() => { if (visible && attachments.length > 0) { @@ -315,6 +316,14 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a const executeImport = async () => { setLoading(true); + setImportStatus('Import wird gestartet...'); + const jobId = crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}`; + const statusPoll = setInterval(async () => { + try { + const status = await emailImportApi.getJobStatus(jobId); + if (status?.message) setImportStatus(status.message); + } catch {} + }, 1500); try { const finalData = []; @@ -358,12 +367,14 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a }); } - await emailImportApi.executeImport(email.Date, finalData); + await emailImportApi.executeImport(email.Date, finalData, jobId); setImportSuccess(true); setCurrentStep(2); } catch (e: any) { message.error(`Fehler beim Import: ${e.message}`); } finally { + clearInterval(statusPoll); + setImportStatus(''); setLoading(false); } }; @@ -747,7 +758,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a style={{ marginBottom: 24 }} /> - +
{currentStep === 0 && renderStep1()} {currentStep === 1 && renderStep2()}