diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index 1af36eb..06faed6 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 { DailyDigestModule } from './daily-digest/daily-digest.module'; import * as path from 'path'; @Module({ @@ -51,6 +52,7 @@ import * as path from 'path'; LabelPrintAgentModule, AgrarmonitorModule, FreigabeModule, + DailyDigestModule, ], }) export class AppModule {} diff --git a/paperless-backend/src/daily-digest/daily-digest.controller.ts b/paperless-backend/src/daily-digest/daily-digest.controller.ts new file mode 100644 index 0000000..684c8fe --- /dev/null +++ b/paperless-backend/src/daily-digest/daily-digest.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Post, Request, HttpCode } from '@nestjs/common'; +import { DailyDigestService } from './daily-digest.service'; + +@Controller('api/daily-digest') +export class DailyDigestController { + constructor(private readonly dailyDigestService: DailyDigestService) {} + + @Post('send-now') + @HttpCode(200) + async sendNow(@Request() req: any) { + const { userId, email, preferredUsername } = req.user; + if (!email) { + return { ok: false, error: 'Keine E-Mail-Adresse im Benutzerprofil gefunden.' }; + } + try { + await this.dailyDigestService.sendDigestForUser(userId, email, preferredUsername); + return { ok: true }; + } catch (err: any) { + return { ok: false, error: err.message }; + } + } +} diff --git a/paperless-backend/src/daily-digest/daily-digest.module.ts b/paperless-backend/src/daily-digest/daily-digest.module.ts new file mode 100644 index 0000000..01ee641 --- /dev/null +++ b/paperless-backend/src/daily-digest/daily-digest.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DailyDigestService } from './daily-digest.service'; +import { DailyDigestController } from './daily-digest.controller'; +import { StatsModule } from '../stats/stats.module'; +import { UserSettingsModule } from '../user-settings/user-settings.module'; +import { PostprocessingModule } from '../postprocessing/postprocessing.module'; + +@Module({ + imports: [StatsModule, UserSettingsModule, PostprocessingModule], + providers: [DailyDigestService], + controllers: [DailyDigestController], +}) +export class DailyDigestModule {} diff --git a/paperless-backend/src/daily-digest/daily-digest.service.ts b/paperless-backend/src/daily-digest/daily-digest.service.ts new file mode 100644 index 0000000..a0d692c --- /dev/null +++ b/paperless-backend/src/daily-digest/daily-digest.service.ts @@ -0,0 +1,141 @@ +import { Injectable, Logger } from '@nestjs/common'; +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'; + +@Injectable() +export class DailyDigestService { + private readonly logger = new Logger(DailyDigestService.name); + + constructor( + private readonly statsService: StatsService, + private readonly userSettingsService: UserSettingsService, + private readonly mailService: MailService, + ) {} + + async sendDigestForUser(userId: string, email: string, preferredUsername?: string) { + 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); + const plainText = buildDigestPlainText(counts, today); + await this.mailService.sendMail({ + to: email, + subject: `Paperless Manager – Tagesübersicht ${today}`, + body: plainText, + html, + }); + this.logger.log(`Manueller Digest gesendet an ${email} (userId: ${userId})`); + } + + @Cron(process.env.DAILY_DIGEST_CRON || '0 7 * * *') + async sendDailyDigests() { + this.logger.log('Starte täglichen E-Mail-Digest...'); + + const subscribers = await this.userSettingsService.findAllDigestSubscribers(); + if (subscribers.length === 0) { + this.logger.log('Keine Abonnenten für den täglichen Digest.'); + return; + } + + const today = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + + for (const sub of subscribers) { + try { + const counts = await this.statsService.getDashboardCounts(sub.UserPreferredUsername ?? undefined); + const html = buildDigestHtml(counts, today); + const plainText = buildDigestPlainText(counts, today); + await this.mailService.sendMail({ + to: sub.UserEmail!, + subject: `Paperless Manager – Tagesübersicht ${today}`, + body: plainText, + html, + }); + this.logger.log(`Digest gesendet an ${sub.UserEmail}`); + } catch (err) { + this.logger.error(`Fehler beim Senden des Digests an ${sub.UserEmail}: ${err.message}`); + } + } + } +} + +function countColor(n: number): string { + if (n === 0) return '#16a34a'; + if (n <= 5) return '#d97706'; + return '#dc2626'; +} + +function buildDigestHtml(counts: DashboardCounts, today: string): string { + const rows: { label: string; count: number }[] = [ + { label: 'Eingangsbox (Scanner)', count: counts.inbox }, + { label: 'Posteingang', count: counts.posteingang }, + { label: 'Manuell bearbeiten', count: counts.manuell }, + { label: 'Mailpostfach', count: counts.mailpostfach }, + { label: 'In Agrarmonitor', count: counts.agrarmonitor }, + ]; + + const tableRows = rows + .map( + r => ` + + ${r.label} + ${r.count} + `, + ) + .join(''); + + return ` + + + + + +
+ + + + + + + + + + +
+

Paperless Manager

+

Tagesübersicht – ${today}

+
+

Hier ist Ihre aktuelle Übersicht der offenen Vorgänge:

+ + + + + + + + ${tableRows} +
BereichOffen
+
+

+ Diese E-Mail wird täglich automatisch von Paperless Manager versendet.
+ Sie können den Digest in den Benutzereinstellungen deaktivieren. +

+
+
+ +`; +} + +function buildDigestPlainText(counts: DashboardCounts, today: string): string { + 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} + +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 2fec5b4..7e3c94f 100644 --- a/paperless-backend/src/database/entities/user-settings.entity.ts +++ b/paperless-backend/src/database/entities/user-settings.entity.ts @@ -34,4 +34,13 @@ export class UserSettings { @Column({ type: 'json', nullable: true }) EmailRecipientHistory!: string[] | null; + + @Column({ type: 'boolean', default: false }) + DailyDigestEnabled!: boolean; + + @Column({ type: 'varchar', length: 255, nullable: true }) + UserEmail!: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + UserPreferredUsername!: string | null; } diff --git a/paperless-backend/src/stats/stats.controller.ts b/paperless-backend/src/stats/stats.controller.ts index a8647ae..2074362 100644 --- a/paperless-backend/src/stats/stats.controller.ts +++ b/paperless-backend/src/stats/stats.controller.ts @@ -1,90 +1,12 @@ -import { Controller, Get, Request, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Email } from '../database/entities/email.entity'; -import { InboxService } from '../inbox/inbox.service'; -import { PaperlessService } from '../paperless/paperless.service'; -import { ConfigService } from '@nestjs/config'; +import { Controller, Get, Request } from '@nestjs/common'; +import { StatsService } from './stats.service'; @Controller('api/stats') export class StatsController { - private readonly logger = new Logger(StatsController.name); - - constructor( - @InjectRepository(Email) private readonly emailRepo: Repository, - private readonly inboxService: InboxService, - private readonly paperlessService: PaperlessService, - private readonly configService: ConfigService, - ) {} + constructor(private readonly statsService: StatsService) {} @Get('counts') async getCounts(@Request() req: any) { - let inboxCount = 0; - let posteingangCount = 0; - let manuellCount = 0; - let mailpostfachCount = 0; - let agrarmonitorCount = 0; - - // 1. Eingangsbox (Dateien aus /mnt/scans) - if (req.user) { - try { - const files = await this.inboxService.listFiles(req.user.preferredUsername ?? null); - inboxCount = files.length; - } catch (err) { - this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message); - } - } - - // 2. Posteingang (Paperless tag 1) - try { - const response1 = await this.paperlessService.getDocuments({ - page: 1, - page_size: 1, - tags__id__all: 1, - }); - posteingangCount = response1.count || 0; - } catch (err) { - this.logger.error('Fehler beim Abrufen der Posteingang-Stats: ' + err.message); - } - - // 3. Manuell bearbeiten (Paperless tag errorTag) - try { - const errorTag = this.configService.get('MANUELL_BEARBEITEN_TAG', 6); - const response2 = await this.paperlessService.getDocuments({ - page: 1, - page_size: 1, - tags__id__all: errorTag, - }); - manuellCount = response2.count || 0; - } catch (err) { - this.logger.error('Fehler beim Abrufen der Manuell-Stats: ' + err.message); - } - - // 4. Mailpostfach (Emails with status 0) - try { - mailpostfachCount = await this.emailRepo.count({ where: { Status: 0 } }); - } catch (err) { - this.logger.error('Fehler beim Abrufen der E-Mail-Stats: ' + err.message); - } - - // 5. Agrarmonitor (Paperless tag 3) - try { - const agrarmonitorResponse = await this.paperlessService.getDocuments({ - page: 1, - page_size: 1, - tags__id__all: 3, - }); - agrarmonitorCount = agrarmonitorResponse.count || 0; - } catch (err) { - this.logger.error('Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message); - } - - return { - inbox: inboxCount, - posteingang: posteingangCount, - manuell: manuellCount, - mailpostfach: mailpostfachCount, - agrarmonitor: agrarmonitorCount, - }; + return this.statsService.getDashboardCounts(req.user?.preferredUsername); } } diff --git a/paperless-backend/src/stats/stats.module.ts b/paperless-backend/src/stats/stats.module.ts index b4e4462..0fecb5e 100644 --- a/paperless-backend/src/stats/stats.module.ts +++ b/paperless-backend/src/stats/stats.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { StatsController } from './stats.controller'; +import { StatsService } from './stats.service'; import { Email } from '../database/entities/email.entity'; import { InboxModule } from '../inbox/inbox.module'; import { PaperlessModule } from '../paperless/paperless.module'; @@ -12,5 +13,7 @@ import { PaperlessModule } from '../paperless/paperless.module'; PaperlessModule, ], controllers: [StatsController], + providers: [StatsService], + exports: [StatsService], }) export class StatsModule {} diff --git a/paperless-backend/src/stats/stats.service.ts b/paperless-backend/src/stats/stats.service.ts new file mode 100644 index 0000000..9b792f8 --- /dev/null +++ b/paperless-backend/src/stats/stats.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Email } from '../database/entities/email.entity'; +import { InboxService } from '../inbox/inbox.service'; +import { PaperlessService } from '../paperless/paperless.service'; + +export interface DashboardCounts { + inbox: number; + posteingang: number; + manuell: number; + mailpostfach: number; + agrarmonitor: number; +} + +@Injectable() +export class StatsService { + private readonly logger = new Logger(StatsService.name); + + constructor( + @InjectRepository(Email) private readonly emailRepo: Repository, + private readonly inboxService: InboxService, + private readonly paperlessService: PaperlessService, + private readonly configService: ConfigService, + ) {} + + async getDashboardCounts(preferredUsername?: string): Promise { + let inboxCount = 0; + let posteingangCount = 0; + let manuellCount = 0; + let mailpostfachCount = 0; + let agrarmonitorCount = 0; + + try { + const files = await this.inboxService.listFiles(preferredUsername ?? null); + inboxCount = files.length; + } catch (err) { + this.logger.error('Fehler beim Abrufen der Inbox-Stats: ' + err.message); + } + + try { + const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 1 }); + posteingangCount = response.count || 0; + } catch (err) { + this.logger.error('Fehler beim Abrufen der Posteingang-Stats: ' + err.message); + } + + try { + const errorTag = this.configService.get('MANUELL_BEARBEITEN_TAG', 6); + const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: errorTag }); + manuellCount = response.count || 0; + } catch (err) { + this.logger.error('Fehler beim Abrufen der Manuell-Stats: ' + err.message); + } + + try { + mailpostfachCount = await this.emailRepo.count({ where: { Status: 0 } }); + } catch (err) { + this.logger.error('Fehler beim Abrufen der E-Mail-Stats: ' + err.message); + } + + try { + const response = await this.paperlessService.getDocuments({ page: 1, page_size: 1, tags__id__all: 3 }); + agrarmonitorCount = response.count || 0; + } catch (err) { + this.logger.error('Fehler beim Abrufen der Agrarmonitor-Stats: ' + err.message); + } + + return { inbox: inboxCount, posteingang: posteingangCount, manuell: manuellCount, mailpostfach: mailpostfachCount, agrarmonitor: agrarmonitorCount }; + } +} diff --git a/paperless-backend/src/user-settings/user-settings.controller.ts b/paperless-backend/src/user-settings/user-settings.controller.ts index c2cb7ba..153cec5 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); + return this.userSettingsService.getSettings(req.user.userId, req.user.email, req.user.preferredUsername); } @Put() async updateSettings(@Request() req: any, @Body() body: any) { - return this.userSettingsService.updateSettings(req.user.userId, body); + return this.userSettingsService.updateSettings(req.user.userId, body, req.user.email, req.user.preferredUsername); } @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 b11c08c..fb0b45e 100644 --- a/paperless-backend/src/user-settings/user-settings.service.ts +++ b/paperless-backend/src/user-settings/user-settings.service.ts @@ -19,6 +19,7 @@ export interface UserSettingsDto { mailSignatureHtml: string | null; defaultLabelTemplateId: number | null; emailRecipientHistory: string[] | null; + dailyDigestEnabled: boolean; } @Injectable() @@ -64,8 +65,16 @@ export class UserSettingsService { return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); } - async getSettings(userId: string): Promise { - const entity = await this.repo.findOne({ where: { UserId: userId } }); + async getSettings(userId: string, email?: string, preferredUsername?: string): Promise { + let entity = await this.repo.findOne({ where: { UserId: userId } }); + if (email || preferredUsername) { + if (!entity) { + entity = this.repo.create({ UserId: userId }); + } + if (email) entity.UserEmail = email; + if (preferredUsername) entity.UserPreferredUsername = preferredUsername; + await this.repo.save(entity); + } return this.toDto(entity); } @@ -82,7 +91,10 @@ export class UserSettingsService { mailSignatureHtml?: string | null; defaultLabelTemplateId?: number | null; emailRecipientHistory?: string[] | null; + dailyDigestEnabled?: boolean; }, + email?: string, + preferredUsername?: string, ): Promise { let entity = await this.repo.findOne({ where: { UserId: userId } }); if (!entity) { @@ -101,6 +113,9 @@ export class UserSettingsService { if (data.mailSignatureHtml !== undefined) entity.MailSignatureHtml = data.mailSignatureHtml; if (data.defaultLabelTemplateId !== undefined) entity.DefaultLabelTemplateId = data.defaultLabelTemplateId; if (data.emailRecipientHistory !== undefined) entity.EmailRecipientHistory = data.emailRecipientHistory; + if (data.dailyDigestEnabled !== undefined) entity.DailyDigestEnabled = data.dailyDigestEnabled; + if (email) entity.UserEmail = email; + if (preferredUsername) entity.UserPreferredUsername = preferredUsername; await this.repo.save(entity); return this.toDto(entity); @@ -144,6 +159,12 @@ export class UserSettingsService { }; } + async findAllDigestSubscribers(): Promise { + return this.repo.find({ + where: { DailyDigestEnabled: true }, + }).then(rows => rows.filter(r => !!r.UserEmail)); + } + async getAvailableSenders(userId: string): Promise<{ id: string; label: string }[]> { const defaultEmail = this.configService.get('SMTP_FROM', 'paperless@localhost'); const defaultName = this.configService.get('SMTP_FROM_NAME', ''); @@ -172,6 +193,7 @@ export class UserSettingsService { mailSignatureHtml: entity?.MailSignatureHtml ?? null, defaultLabelTemplateId: entity?.DefaultLabelTemplateId ?? null, emailRecipientHistory: entity?.EmailRecipientHistory ?? null, + dailyDigestEnabled: entity?.DailyDigestEnabled ?? false, }; } } diff --git a/paperless-frontend/src/api/userSettings.ts b/paperless-frontend/src/api/userSettings.ts index a0a919a..126c116 100644 --- a/paperless-frontend/src/api/userSettings.ts +++ b/paperless-frontend/src/api/userSettings.ts @@ -11,6 +11,7 @@ export interface UserSettingsData { mailSignatureHtml: string | null; defaultLabelTemplateId: number | null; emailRecipientHistory: string[] | null; + dailyDigestEnabled: boolean; } export interface SenderOption { @@ -31,4 +32,7 @@ export const userSettingsApi = { getSenders: () => api.get('/api/user-settings/senders').then((r) => r.data), + + sendDigestNow: () => + api.post<{ ok: boolean; error?: string }>('/api/daily-digest/send-now').then((r) => r.data), }; diff --git a/paperless-frontend/src/pages/UserSettingsPage.tsx b/paperless-frontend/src/pages/UserSettingsPage.tsx index 5f253ae..8fc8ec8 100644 --- a/paperless-frontend/src/pages/UserSettingsPage.tsx +++ b/paperless-frontend/src/pages/UserSettingsPage.tsx @@ -197,6 +197,71 @@ function MailSettingsTab() { ); } +function NotificationsTab() { + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [sending, setSending] = useState(false); + + useEffect(() => { + userSettingsApi.get() + .then((data) => setEnabled(data.dailyDigestEnabled ?? false)) + .catch(() => message.error('Einstellungen konnten nicht geladen werden')) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + await userSettingsApi.update({ dailyDigestEnabled: enabled }); + message.success('Einstellungen gespeichert'); + } catch { + message.error('Speichern fehlgeschlagen'); + } finally { + setSaving(false); + } + }; + + const handleSendNow = async () => { + setSending(true); + try { + const result = await userSettingsApi.sendDigestNow(); + if (result.ok) { + message.success('Tagesübersicht wurde gesendet'); + } else { + message.error(result.error ?? 'Senden fehlgeschlagen'); + } + } catch { + message.error('Senden fehlgeschlagen'); + } finally { + setSending(false); + } + }; + + if (loading) return null; + + return ( +
+ + + + + + + + + +
+ ); +} + export default function UserSettingsPage() { return (
@@ -214,6 +279,11 @@ export default function UserSettingsPage() { label: 'Etikettendruck', children: , }, + { + key: 'notifications', + label: 'Benachrichtigungen', + children: , + }, ]} />