feat: filter digest tiles by user permissions and add import progress status
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s

- Store UserGroups from OIDC in UserSettings entity, sync on each request
- Filter daily digest tiles based on user's permission groups
- Add in-memory job status tracking to EmailImportService
- Poll import job status in MailImportWizard and show progress in Spin tip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 16:29:56 +02:00
parent 2747b0046a
commit 4c75a1ded2
9 changed files with 116 additions and 78 deletions
@@ -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 };
@@ -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<string>('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,
</html>`;
}
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.
`;
@@ -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;
@@ -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)
@@ -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<string, { message: string; done: boolean }>();
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);
}
}
}
@@ -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')
@@ -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<UserSettingsDto> {
async getSettings(userId: string, email?: string, preferredUsername?: string, groups?: string[]): Promise<UserSettingsDto> {
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<UserSettingsDto> {
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);